137 Commits

Author SHA1 Message Date
vcoppe
7ff271f9b9 New translations view.mdx (Spanish) 2025-10-07 02:54:04 +02:00
vcoppe
d75cdd63a9 New translations file.mdx (Spanish) 2025-10-07 02:54:02 +02:00
vcoppe
0a7575d1e4 New translations integration.mdx (Spanish) 2025-10-07 02:54:01 +02:00
vcoppe
ec3022d8ad New translations en.json (Spanish) 2025-10-07 01:50:49 +02:00
vcoppe
d42103b91b New translations en.json (Ukrainian) 2025-10-03 23:57:07 +02:00
vcoppe
00f7d08b04 New translations en.json (Ukrainian) 2025-10-03 22:36:21 +02:00
vcoppe
408cc383cb New translations en.json (Portuguese) 2025-10-03 17:56:00 +02:00
vcoppe
5c926d0ac6 New translations en.json (Ukrainian) 2025-09-23 18:44:01 +02:00
vcoppe
5cb88782fc New translations en.json (Ukrainian) 2025-09-23 15:59:34 +02:00
vcoppe
5eef4e9ece New translations en.json (Russian) 2025-09-20 17:13:17 +02:00
vcoppe
04a2124141 New translations en.json (Italian) 2025-09-20 17:13:15 +02:00
vcoppe
1b6229b2a1 New translations elevation.mdx (Italian) 2025-09-20 16:10:28 +02:00
vcoppe
bca6db50a7 New translations en.json (Italian) 2025-09-20 16:10:27 +02:00
vcoppe
f3aae26996 New translations settings.mdx (Chinese Simplified) 2025-09-10 10:34:12 +02:00
vcoppe
f3c17a8e0f New translations en.json (Indonesian) 2025-09-05 04:13:37 +02:00
vcoppe
d6b24f8753 New translations en.json (Indonesian) 2025-09-05 03:11:41 +02:00
vcoppe
253db0a303 New translations en.json (Norwegian) 2025-09-04 18:13:32 +02:00
vcoppe
8499e52461 New translations en.json (Dutch) 2025-09-01 08:57:32 +02:00
vcoppe
d0153179a9 New translations en.json (Indonesian) 2025-08-29 18:50:34 +02:00
vcoppe
264d03727e New translations edit.mdx (Chinese Traditional, Hong Kong) 2025-08-13 18:27:34 +02:00
vcoppe
544405d9b9 New translations en.json (Chinese Traditional, Hong Kong) 2025-08-13 16:32:09 +02:00
vcoppe
24488a3b67 New translations en.json (Indonesian) 2025-08-08 14:15:35 +02:00
vcoppe
ae78185b29 New translations en.json (Indonesian) 2025-08-08 12:50:22 +02:00
vcoppe
7f682b24ef New translations en.json (Indonesian) 2025-08-04 04:32:52 +02:00
vcoppe
d42a52d8cf New translations en.json (Norwegian) 2025-08-03 16:13:45 +02:00
vcoppe
b85df15890 New translations en.json (Norwegian) 2025-08-03 15:00:56 +02:00
vcoppe
393499f34f New translations en.json (Indonesian) 2025-08-02 13:58:57 +02:00
vcoppe
c656d0f9b5 New translations en.json (Indonesian) 2025-08-02 12:40:02 +02:00
vcoppe
32017a8859 New translations en.json (Indonesian) 2025-08-02 11:35:56 +02:00
vcoppe
d87c5b1140 New translations en.json (Norwegian) 2025-08-01 22:28:15 +02:00
vcoppe
f59f783d3f New translations en.json (Norwegian) 2025-08-01 21:18:36 +02:00
vcoppe
ec298eac61 New translations elevation.mdx (Indonesian) 2025-08-01 16:14:37 +02:00
vcoppe
81a25bb4ee New translations faq.mdx (Indonesian) 2025-08-01 16:14:36 +02:00
vcoppe
e99f044e45 New translations time.mdx (Indonesian) 2025-08-01 16:14:35 +02:00
vcoppe
5ae25a5fd9 New translations scissors.mdx (Indonesian) 2025-08-01 16:14:34 +02:00
vcoppe
e9d1cb4907 New translations routing.mdx (Indonesian) 2025-08-01 16:14:33 +02:00
vcoppe
99f8ca2dca New translations poi.mdx (Indonesian) 2025-08-01 16:14:31 +02:00
vcoppe
ddea5d38b5 New translations minify.mdx (Indonesian) 2025-08-01 16:14:30 +02:00
vcoppe
31d2b83550 New translations merge.mdx (Indonesian) 2025-08-01 16:14:29 +02:00
vcoppe
5535e56ed2 New translations extract.mdx (Indonesian) 2025-08-01 16:14:28 +02:00
vcoppe
d740b95dbc New translations clean.mdx (Indonesian) 2025-08-01 16:14:26 +02:00
vcoppe
ae92e9a945 New translations toolbar.mdx (Indonesian) 2025-08-01 16:14:25 +02:00
vcoppe
29730c3896 New translations view.mdx (Indonesian) 2025-08-01 16:14:24 +02:00
vcoppe
a5ae8270f0 New translations settings.mdx (Indonesian) 2025-08-01 16:14:23 +02:00
vcoppe
54f5fa6432 New translations file.mdx (Indonesian) 2025-08-01 16:14:22 +02:00
vcoppe
0260644063 New translations edit.mdx (Indonesian) 2025-08-01 16:14:20 +02:00
vcoppe
267fc03a82 New translations menu.mdx (Indonesian) 2025-08-01 16:14:19 +02:00
vcoppe
bf1537584c New translations map-controls.mdx (Indonesian) 2025-08-01 16:14:18 +02:00
vcoppe
9ee7825022 New translations integration.mdx (Indonesian) 2025-08-01 16:14:17 +02:00
vcoppe
2be0c42dd1 New translations translation.mdx (Indonesian) 2025-08-01 16:14:16 +02:00
vcoppe
3423c053a2 New translations mapbox.mdx (Indonesian) 2025-08-01 16:14:15 +02:00
vcoppe
26923cca00 New translations funding.mdx (Indonesian) 2025-08-01 16:14:14 +02:00
vcoppe
36e027659c New translations gpx.mdx (Indonesian) 2025-08-01 16:14:13 +02:00
vcoppe
f447dccdb4 New translations getting-started.mdx (Indonesian) 2025-08-01 16:14:11 +02:00
vcoppe
69eae32851 New translations files-and-stats.mdx (Indonesian) 2025-08-01 16:14:10 +02:00
vcoppe
aa2fcfb8cb New translations en.json (Indonesian) 2025-08-01 16:14:09 +02:00
vcoppe
fae5ef2a41 New translations en.json (Norwegian) 2025-07-31 23:43:31 +02:00
vcoppe
7251ca7d2d New translations toolbar.mdx (Norwegian) 2025-07-31 22:34:29 +02:00
vcoppe
7cdbd919bf New translations en.json (Norwegian) 2025-07-31 22:34:27 +02:00
vcoppe
d450f95602 New translations en.json (Dutch) 2025-07-31 14:26:59 +02:00
vcoppe
5a65201971 New translations en.json (Thai) 2025-07-30 18:35:07 +02:00
vcoppe
d303b8db3e New translations gpx.mdx (Portuguese) 2025-07-20 19:33:06 +02:00
vcoppe
06baa33827 New translations gpx.mdx (Portuguese) 2025-07-20 18:31:13 +02:00
vcoppe
42743e637e New translations en.json (French) 2025-07-18 16:38:10 +02:00
vcoppe
9969fd7dec New translations edit.mdx (Swedish) 2025-07-17 23:06:28 +02:00
vcoppe
fc6d5c2a1d New translations en.json (Basque) 2025-07-16 07:51:58 +02:00
vcoppe
f8abb1ca24 New translations elevation.mdx (Thai) 2025-07-15 14:10:56 +02:00
vcoppe
a5af38ae3d New translations faq.mdx (Thai) 2025-07-15 14:10:55 +02:00
vcoppe
aab70951dc New translations time.mdx (Thai) 2025-07-15 14:10:54 +02:00
vcoppe
334cacf93c New translations scissors.mdx (Thai) 2025-07-15 14:10:52 +02:00
vcoppe
53024012fc New translations routing.mdx (Thai) 2025-07-15 14:10:51 +02:00
vcoppe
86a72f77c1 New translations poi.mdx (Thai) 2025-07-15 14:10:50 +02:00
vcoppe
bc11a5ad0a New translations minify.mdx (Thai) 2025-07-15 14:10:49 +02:00
vcoppe
8f2d217fd4 New translations merge.mdx (Thai) 2025-07-15 14:10:47 +02:00
vcoppe
183727cd50 New translations extract.mdx (Thai) 2025-07-15 14:10:46 +02:00
vcoppe
676e87591a New translations clean.mdx (Thai) 2025-07-15 14:10:44 +02:00
vcoppe
8c05fc4da0 New translations toolbar.mdx (Thai) 2025-07-15 14:10:43 +02:00
vcoppe
2bab06561e New translations view.mdx (Thai) 2025-07-15 14:10:42 +02:00
vcoppe
dfa7e2f5bb New translations settings.mdx (Thai) 2025-07-15 14:10:41 +02:00
vcoppe
78bece5616 New translations file.mdx (Thai) 2025-07-15 14:10:39 +02:00
vcoppe
eeea15e373 New translations edit.mdx (Thai) 2025-07-15 14:10:38 +02:00
vcoppe
80cd513ab7 New translations menu.mdx (Thai) 2025-07-15 14:10:37 +02:00
vcoppe
942ef1615e New translations map-controls.mdx (Thai) 2025-07-15 14:10:35 +02:00
vcoppe
a354698022 New translations integration.mdx (Thai) 2025-07-15 14:10:34 +02:00
vcoppe
0cdea488c9 New translations translation.mdx (Thai) 2025-07-15 14:10:33 +02:00
vcoppe
4f4291ac47 New translations mapbox.mdx (Thai) 2025-07-15 14:10:32 +02:00
vcoppe
bf0cf03091 New translations funding.mdx (Thai) 2025-07-15 14:10:30 +02:00
vcoppe
f7da09f20f New translations gpx.mdx (Thai) 2025-07-15 14:10:28 +02:00
vcoppe
be1529331c New translations getting-started.mdx (Thai) 2025-07-15 14:10:27 +02:00
vcoppe
301d658a29 New translations files-and-stats.mdx (Thai) 2025-07-15 14:10:26 +02:00
vcoppe
1cc54e5b2c New translations en.json (Thai) 2025-07-15 14:10:25 +02:00
vcoppe
65a7fd21e7 New translations en.json (Italian) 2025-07-14 12:56:13 +02:00
vcoppe
856537c0cd New translations en.json (Ukrainian) 2025-07-10 01:33:15 +02:00
vcoppe
b2a88e0063 New translations en.json (Ukrainian) 2025-07-10 00:32:33 +02:00
vcoppe
85a7068785 New translations en.json (Ukrainian) 2025-07-09 12:14:44 +02:00
vcoppe
cbb733d99a New translations settings.mdx (Ukrainian) 2025-07-07 18:53:29 +02:00
vcoppe
ce88c94a19 New translations edit.mdx (Ukrainian) 2025-07-07 18:53:28 +02:00
vcoppe
16516915d8 New translations translation.mdx (Ukrainian) 2025-07-07 18:53:27 +02:00
vcoppe
6addb8da23 New translations mapbox.mdx (Ukrainian) 2025-07-07 18:53:26 +02:00
vcoppe
bc7f664fd8 New translations funding.mdx (Ukrainian) 2025-07-07 17:28:09 +02:00
vcoppe
aac17aa33c New translations en.json (Ukrainian) 2025-07-07 17:28:08 +02:00
vcoppe
825500e207 New translations en.json (Ukrainian) 2025-07-07 15:46:23 +02:00
vcoppe
4d42016c72 New translations en.json (Italian) 2025-06-28 14:49:34 +02:00
vcoppe
9d665df602 New translations poi.mdx (Polish) 2025-06-23 23:44:31 +02:00
vcoppe
9087f69fb0 New translations minify.mdx (Polish) 2025-06-23 23:44:30 +02:00
vcoppe
2a06f6a214 New translations clean.mdx (Polish) 2025-06-23 23:44:29 +02:00
vcoppe
78a8428bd0 New translations toolbar.mdx (Polish) 2025-06-23 23:44:28 +02:00
vcoppe
0d235768fa New translations menu.mdx (Polish) 2025-06-23 23:44:26 +02:00
vcoppe
af092bbdec New translations edit.mdx (Polish) 2025-06-23 22:23:55 +02:00
vcoppe
4961630d62 New translations en.json (Chinese Traditional, Hong Kong) 2025-06-20 05:53:38 +02:00
vcoppe
81920b9ab9 New translations en.json (Chinese Traditional, Hong Kong) 2025-06-20 04:39:50 +02:00
vcoppe
9e031d3b5b New translations en.json (Chinese Traditional, Hong Kong) 2025-06-20 03:33:14 +02:00
vcoppe
7ae3ed6d2a New translations elevation.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:44 +02:00
vcoppe
05d79f2b51 New translations faq.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:43 +02:00
vcoppe
274e591354 New translations time.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:42 +02:00
vcoppe
95fd152b3d New translations scissors.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:41 +02:00
vcoppe
ffc91ed6d8 New translations routing.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:40 +02:00
vcoppe
de0b759875 New translations poi.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:38 +02:00
vcoppe
f041dcf944 New translations minify.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:37 +02:00
vcoppe
946b9bd9d1 New translations merge.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:36 +02:00
vcoppe
db77a69838 New translations extract.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:35 +02:00
vcoppe
d10f4d26e2 New translations clean.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:34 +02:00
vcoppe
6b62d686ba New translations toolbar.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:33 +02:00
vcoppe
065826e64d New translations view.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:32 +02:00
vcoppe
a3b096343f New translations settings.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:31 +02:00
vcoppe
b33be91b06 New translations file.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:29 +02:00
vcoppe
a94a1816c5 New translations edit.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:28 +02:00
vcoppe
9a9e7fea07 New translations menu.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:27 +02:00
vcoppe
9a03042077 New translations map-controls.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:26 +02:00
vcoppe
704d3b2d6b New translations integration.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:24 +02:00
vcoppe
e5c2be238d New translations translation.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:23 +02:00
vcoppe
9feea07527 New translations mapbox.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:22 +02:00
vcoppe
b0967d03b8 New translations funding.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:21 +02:00
vcoppe
d33fd71f93 New translations gpx.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:20 +02:00
vcoppe
226b5b2682 New translations getting-started.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:18 +02:00
vcoppe
f8879b0223 New translations files-and-stats.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:17 +02:00
vcoppe
ada09d96c4 New translations en.json (Chinese Traditional, Hong Kong) 2025-06-19 18:26:16 +02:00
914 changed files with 16926 additions and 12749 deletions

View File

@@ -26,7 +26,6 @@ Any help is greatly appreciated!
## Development ## Development
The code is split into two parts: The code is split into two parts:
- `gpx`: a Typescript library for parsing and manipulating GPX files, - `gpx`: a Typescript library for parsing and manipulating GPX files,
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application. - `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
@@ -58,9 +57,10 @@ This project has been made possible thanks to the following open source projects
- Development: - Development:
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience - [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience
- [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation - [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation
- [svelte-i18n](https://github.com/kaisermann/svelte-i18n) — easy localization
- Design: - Design:
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components - [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
- [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons - [lucide-svelte](https://github.com/lucide-icons/lucide/tree/main/packages/lucide-svelte) — beautiful icons
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling - [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts - [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
- Logic: - Logic:

1
gpx/.prettierignore Normal file
View File

@@ -0,0 +1 @@
package-lock.json

View File

@@ -1,16 +1,14 @@
{ {
"$schema": "https://next.shadcn-svelte.com/schema.json", "$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": { "tailwind": {
"config": "tailwind.config.js",
"css": "src/app.css", "css": "src/app.css",
"baseColor": "slate" "baseColor": "slate"
}, },
"aliases": { "aliases": {
"components": "$lib/components", "components": "$lib/components",
"utils": "$lib/utils", "utils": "$lib/utils"
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
}, },
"typescript": true, "typescript": true
"registry": "https://next.shadcn-svelte.com/registry"
} }

6332
website/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,8 @@
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"prebuild": "npx tsx src/lib/scripts/pwa-manifest.ts", "prebuild": "npx tsx src/lib/pwa-manifest.ts",
"postbuild": "npx tsx src/lib/scripts/sitemap.ts", "postbuild": "npx tsx src/lib/sitemap.ts",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -14,70 +14,69 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@lucide/svelte": "^0.513.0", "@sveltejs/adapter-auto": "^3.2.5",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/enhanced-img": "^0.6.0", "@sveltejs/enhanced-img": "^0.3.8",
"@sveltejs/kit": "^2.21.2", "@sveltejs/kit": "^2.6.1",
"@sveltejs/vite-plugin-svelte": "^5.1.0", "@sveltejs/vite-plugin-svelte": "^3.1.2",
"@tailwindcss/vite": "^4.1.8", "@types/eslint": "^8.56.12",
"@types/eslint": "^9.6.1",
"@types/events": "^3.0.3", "@types/events": "^3.0.3",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/mapbox__tilebelt": "^1.0.4", "@types/mapbox__tilebelt": "^1.0.4",
"@types/mapbox-gl": "^3.4.1", "@types/mapbox-gl": "^3.4.0",
"@types/node": "^22.15.30", "@types/node": "^20.16.10",
"@types/png.js": "^0.2.3", "@types/png.js": "^0.2.3",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.13.0",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^8.33.1", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^8.33.1", "@typescript-eslint/parser": "^7.18.0",
"bits-ui": "^2.5.0", "autoprefixer": "^10.4.20",
"eslint": "^9.28.0", "eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^3.9.1", "eslint-plugin-svelte": "^2.44.1",
"events": "^3.3.0", "events": "^3.3.0",
"glob": "^11.0.2", "glob": "^10.4.5",
"lucide-static": "^0.513.0",
"mdsvex": "^0.12.6", "mdsvex": "^0.12.6",
"mode-watcher": "^1.0.7",
"paneforge": "^1.0.0-next.5",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.5.3", "prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.2.7",
"svelte": "^5.33.18", "svelte": "^4.2.19",
"svelte-check": "^4.0.0", "svelte-check": "^3.8.6",
"svelte-sonner": "^1.0.4", "tailwindcss": "^3.4.13",
"tailwindcss": "^4.1.8", "tslib": "^2.7.0",
"tslib": "^2.8.1",
"tsx": "^4.19.1", "tsx": "^4.19.1",
"tw-animate-css": "^1.3.4", "typescript": "^5.6.2",
"typescript": "^5.8.3", "vite": "^5.4.8",
"vaul-svelte": "^1.0.0-next.7", "vite-plugin-node-polyfills": "^0.22.0"
"vite": "^6.3.5",
"vite-plugin-node-polyfills": "^0.23.0"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@docsearch/js": "^3.9.0", "@docsearch/js": "^3.6.2",
"@internationalized/date": "^3.8.2", "@internationalized/date": "^3.5.5",
"@mapbox/mapbox-gl-geocoder": "^5.0.3", "@mapbox/mapbox-gl-geocoder": "^5.0.3",
"@mapbox/sphericalmercator": "^2.0.1", "@mapbox/sphericalmercator": "^1.2.0",
"@mapbox/tilebelt": "^2.0.2", "@mapbox/tilebelt": "^1.0.2",
"@types/mapbox__sphericalmercator": "^1.2.3", "@types/mapbox__sphericalmercator": "^1.2.3",
"chart.js": "^4.4.9", "bits-ui": "^0.21.15",
"chartjs-plugin-zoom": "^2.2.0", "chart.js": "^4.4.4",
"chartjs-plugin-zoom": "^2.0.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.0.11", "dexie": "^4.0.8",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"gpx": "file:../gpx", "gpx": "file:../gpx",
"immer": "^10.1.1", "immer": "^10.1.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"mapbox-gl": "^3.12.0", "lucide-static": "^0.460.0",
"lucide-svelte": "^0.460.1",
"mapbox-gl": "^3.11.1",
"mapillary-js": "^4.1.2", "mapillary-js": "^4.1.2",
"mode-watcher": "^0.3.1",
"png.js": "^0.2.1", "png.js": "^0.2.1",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.13.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.3",
"tailwind-merge": "^3.3.0", "svelte-i18n": "^4.0.0",
"tailwind-variants": "^1.0.0" "svelte-sonner": "^0.3.28",
"tailwind-merge": "^2.5.2",
"tailwind-variants": "^0.2.1"
} }
} }

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,122 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: hsl(0 0% 100%) /* <- Wrap in HSL */;
--foreground: hsl(240 10% 3.9%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 72.2% 50.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 10% 3.9%);
--sidebar: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--support: rgb(220 15 130);
--link: rgb(0 110 180);
--radius: 0.5rem;
}
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 4.9% 83.9%);
--sidebar: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--support: rgb(255 110 190);
--link: rgb(80 190 255);
}
@theme inline {
/* Radius (for rounded-*) */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* Colors */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring);
--color-radius: var(--radius);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-support: var(--support);
--color-link: var(--link);
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

86
website/src/app.pcss Normal file
View File

@@ -0,0 +1,86 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 45%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 92%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 210 40% 98%;
--support: 220 15 130;
--link: 0 110 180;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 30%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--support: 255 110 190;
--link: 80 190 255;
--ring: hsl(212.7, 26.8%, 83.9);
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -41,12 +41,10 @@ export async function handle({ event, resolve }) {
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" /> <link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />
<link rel="manifest" href="/${language}.manifest.webmanifest" />`; <link rel="manifest" href="/${language}.manifest.webmanifest" />`;
if (page !== '404') {
for (let lang of Object.keys(languages)) { 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${getURLForLanguage(lang, path)}" />
`; `;
} }
}
const response = await resolve(event, { const response = await resolve(event, {
transformPageChunk: ({ html }) => transformPageChunk: ({ html }) =>

View File

@@ -29,8 +29,7 @@ import {
TriangleAlert, TriangleAlert,
Anchor, Anchor,
Toilet, Toilet,
type IconProps, } from 'lucide-svelte';
} from '@lucide/svelte';
import { import {
Landmark as LandmarkSvg, Landmark as LandmarkSvg,
Shell as ShellSvg, Shell as ShellSvg,
@@ -62,11 +61,11 @@ import {
Anchor as AnchorSvg, Anchor as AnchorSvg,
Toilet as ToiletSvg, Toilet as ToiletSvg,
} from 'lucide-static'; } from 'lucide-static';
import type { Component } from 'svelte'; import type { ComponentType } from 'svelte';
export type Symbol = { export type Symbol = {
value: string; value: string;
icon?: Component<IconProps>; icon?: ComponentType<Icon>;
iconSvg?: string; iconSvg?: string;
}; };

View File

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

View File

@@ -1,38 +1,28 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js'; import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import type { Snippet } from 'svelte'; import type { Builder } from 'bits-ui';
const { export let variant:
variant = 'default', | 'default'
label, | 'secondary'
side = 'top', | 'link'
disabled = false, | 'destructive'
class: className = '', | 'outline'
children, | 'ghost'
onclick, | undefined = 'default';
}: { export let label: string;
variant?: 'default' | 'secondary' | 'link' | 'destructive' | 'outline' | 'ghost'; export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
label: string; export let builders: Builder[] = [];
side?: 'top' | 'right' | 'bottom' | 'left';
disabled?: boolean;
class?: string;
children: Snippet;
onclick?: (event: MouseEvent) => void;
} = $props();
</script> </script>
<Tooltip.Provider> <Tooltip.Root>
<Tooltip.Root> <Tooltip.Trigger asChild let:builder>
<Tooltip.Trigger> <Button builders={[...builders, builder]} {variant} {...$$restProps} on:click>
{#snippet child({ props })} <slot />
<Button {...props} {variant} class={className} {onclick}>
{@render children()}
</Button> </Button>
{/snippet}
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content {side}> <Tooltip.Content {side}>
<span>{label}</span> <span>{label}</span>
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider>

View File

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

View File

@@ -18,9 +18,9 @@
Check, Check,
ChartNoAxesColumn, ChartNoAxesColumn,
Construction, Construction,
} from '@lucide/svelte'; } from 'lucide-svelte';
import { getSlopeColor, getSurfaceColor, getHighwayColor } from '$lib/assets/colors'; import { getSlopeColor, getSurfaceColor, getHighwayColor } from '$lib/assets/colors';
import { _, df } from '$lib/i18n.svelte'; import { _ } from 'svelte-i18n';
import { import {
getCadenceWithUnits, getCadenceWithUnits,
getConvertedDistance, getConvertedDistance,
@@ -39,6 +39,7 @@
import type { GPXStatistics } from 'gpx'; import type { GPXStatistics } from 'gpx';
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { mode } from 'mode-watcher'; import { mode } from 'mode-watcher';
import { df } from '$lib/utils';
export let gpxStatistics: Writable<GPXStatistics>; export let gpxStatistics: Writable<GPXStatistics>;
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>; export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
@@ -120,17 +121,17 @@
marker.addTo($map); marker.addTo($map);
} }
} }
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`; return `${$_('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 1) { } else if (context.datasetIndex === 1) {
return `${$velocityUnits === 'speed' ? i18n._('quantities.speed') : i18n._('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`; return `${$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 2) { } else if (context.datasetIndex === 2) {
return `${i18n._('quantities.heartrate')}: ${getHeartRateWithUnits(point.y)}`; return `${$_('quantities.heartrate')}: ${getHeartRateWithUnits(point.y)}`;
} else if (context.datasetIndex === 3) { } else if (context.datasetIndex === 3) {
return `${i18n._('quantities.cadence')}: ${getCadenceWithUnits(point.y)}`; return `${$_('quantities.cadence')}: ${getCadenceWithUnits(point.y)}`;
} else if (context.datasetIndex === 4) { } else if (context.datasetIndex === 4) {
return `${i18n._('quantities.temperature')}: ${getTemperatureWithUnits(point.y, false)}`; return `${$_('quantities.temperature')}: ${getTemperatureWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 5) { } else if (context.datasetIndex === 5) {
return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`; return `${$_('quantities.power')}: ${getPowerWithUnits(point.y)}`;
} }
}, },
afterBody: function (contexts: Chart.TooltipContext[]) { afterBody: function (contexts: Chart.TooltipContext[]) {
@@ -152,35 +153,31 @@
let mtbScale = point.extensions.mtb_scale; let mtbScale = point.extensions.mtb_scale;
let labels = [ let labels = [
` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`, ` ${$_('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
` ${i18n._('quantities.slope')}: ${slope.at} %${elevationFill === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`, ` ${$_('quantities.slope')}: ${slope.at} %${elevationFill === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`,
]; ];
if (elevationFill === 'surface') { if (elevationFill === 'surface') {
labels.push( labels.push(
` ${i18n._('quantities.surface')}: ${i18n._(`toolbar.routing.surface.${surface}`)}` ` ${$_('quantities.surface')}: ${$_(`toolbar.routing.surface.${surface}`)}`
); );
} }
if (elevationFill === 'highway') { if (elevationFill === 'highway') {
labels.push( labels.push(
` ${i18n._('quantities.highway')}: ${i18n._(`toolbar.routing.highway.${highway}`)}${ ` ${$_('quantities.highway')}: ${$_(`toolbar.routing.highway.${highway}`)}${
sacScale sacScale
? ` (${i18n._(`toolbar.routing.sac_scale.${sacScale}`)})` ? ` (${$_(`toolbar.routing.sac_scale.${sacScale}`)})`
: '' : ''
}` }`
); );
if (mtbScale) { if (mtbScale) {
labels.push( labels.push(` ${$_('toolbar.routing.mtb_scale')}: ${mtbScale}`);
` ${i18n._('toolbar.routing.mtb_scale')}: ${mtbScale}`
);
} }
} }
if (point.time) { if (point.time) {
labels.push( labels.push(` ${$_('quantities.time')}: ${df.format(point.time)}`);
` ${i18n._('quantities.time')}: ${$df.format(point.time)}`
);
} }
return labels; return labels;
@@ -361,7 +358,7 @@
// update data // update data
chart.data.datasets[0] = { chart.data.datasets[0] = {
label: i18n._('quantities.elevation'), label: $_('quantities.elevation'),
data: data.local.points.map((point, index) => { data: data.local.points.map((point, index) => {
return { return {
x: getConvertedDistance(data.local.distance.total[index]), x: getConvertedDistance(data.local.distance.total[index]),
@@ -515,8 +512,8 @@
// Draw selection rectangle // Draw selection rectangle
let selectionContext = overlay.getContext('2d'); let selectionContext = overlay.getContext('2d');
if (selectionContext) { if (selectionContext) {
selectionContext.fillStyle = mode.current === 'dark' ? 'white' : 'black'; selectionContext.fillStyle = $mode === 'dark' ? 'white' : 'black';
selectionContext.globalAlpha = mode.current === 'dark' ? 0.2 : 0.1; selectionContext.globalAlpha = $mode === 'dark' ? 0.2 : 0.1;
selectionContext.clearRect(0, 0, overlay.width, overlay.height); selectionContext.clearRect(0, 0, overlay.width, overlay.height);
let startPixel = chart.scales.x.getPixelForValue( let startPixel = chart.scales.x.getPixelForValue(
@@ -541,7 +538,7 @@
} }
} }
$: $slicedGPXStatistics, mode.current, updateOverlay(); $: $slicedGPXStatistics, $mode, updateOverlay();
onDestroy(() => { onDestroy(() => {
if (chart) { if (chart) {
@@ -556,17 +553,15 @@
{#if showControls} {#if showControls}
<div class="absolute bottom-10 right-1.5"> <div class="absolute bottom-10 right-1.5">
<Popover.Root> <Popover.Root>
<Popover.Trigger> <Popover.Trigger asChild let:builder>
{#snippet child({ props })}
<ButtonWithTooltip <ButtonWithTooltip
{...props} label={$_('chart.settings')}
label={i18n._('chart.settings')} builders={[builder]}
variant="outline" variant="outline"
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 hover:bg-background" class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 hover:bg-background"
> >
<ChartNoAxesColumn size="18" /> <ChartNoAxesColumn size="18" />
</ButtonWithTooltip> </ButtonWithTooltip>
{/snippet}
</Popover.Trigger> </Popover.Trigger>
<Popover.Content <Popover.Content
class="w-fit p-0 flex flex-col divide-y" class="w-fit p-0 flex flex-col divide-y"
@@ -588,7 +583,7 @@
{/if} {/if}
</div> </div>
<TriangleRight size="15" class="mr-1" /> <TriangleRight size="15" class="mr-1" />
{i18n._('quantities.slope')} {$_('quantities.slope')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground" class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
@@ -601,7 +596,7 @@
{/if} {/if}
</div> </div>
<BrickWall size="15" class="mr-1" /> <BrickWall size="15" class="mr-1" />
{i18n._('quantities.surface')} {$_('quantities.surface')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground" class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
@@ -614,7 +609,7 @@
{/if} {/if}
</div> </div>
<Construction size="15" class="mr-1" /> <Construction size="15" class="mr-1" />
{i18n._('quantities.highway')} {$_('quantities.highway')}
</ToggleGroup.Item> </ToggleGroup.Item>
</ToggleGroup.Root> </ToggleGroup.Root>
<ToggleGroup.Root <ToggleGroup.Root
@@ -633,8 +628,8 @@
</div> </div>
<Zap size="15" class="mr-1" /> <Zap size="15" class="mr-1" />
{$velocityUnits === 'speed' {$velocityUnits === 'speed'
? i18n._('quantities.speed') ? $_('quantities.speed')
: i18n._('quantities.pace')} : $_('quantities.pace')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground" class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
@@ -646,7 +641,7 @@
{/if} {/if}
</div> </div>
<HeartPulse size="15" class="mr-1" /> <HeartPulse size="15" class="mr-1" />
{i18n._('quantities.heartrate')} {$_('quantities.heartrate')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground" class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
@@ -658,7 +653,7 @@
{/if} {/if}
</div> </div>
<Orbit size="15" class="mr-1" /> <Orbit size="15" class="mr-1" />
{i18n._('quantities.cadence')} {$_('quantities.cadence')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground" class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
@@ -670,7 +665,7 @@
{/if} {/if}
</div> </div>
<Thermometer size="15" class="mr-1" /> <Thermometer size="15" class="mr-1" />
{i18n._('quantities.temperature')} {$_('quantities.temperature')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground" class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
@@ -682,7 +677,7 @@
{/if} {/if}
</div> </div>
<SquareActivity size="15" class="mr-1" /> <SquareActivity size="15" class="mr-1" />
{i18n._('quantities.power')} {$_('quantities.power')}
</ToggleGroup.Item> </ToggleGroup.Item>
</ToggleGroup.Root> </ToggleGroup.Root>
</Popover.Content> </Popover.Content>

View File

@@ -5,13 +5,14 @@
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { Dialog } from 'bits-ui'; import { Dialog } from 'bits-ui';
import { import {
currentTool,
exportAllFiles, exportAllFiles,
exportSelectedFiles, exportSelectedFiles,
ExportState, ExportState,
exportState, exportState,
} from '$lib/components/export/utils.svelte'; gpxStatistics,
import { tool } from '$lib/components/toolbar/utils.svelte'; } from '$lib/stores';
// import { gpxStatistics } from '$lib/stores'; import { fileObservers } from '$lib/db';
import { import {
Download, Download,
Zap, Zap,
@@ -20,54 +21,23 @@
Orbit, Orbit,
Thermometer, Thermometer,
SquareActivity, SquareActivity,
} from '@lucide/svelte'; } from 'lucide-svelte';
import { i18n } from '$lib/i18n.svelte'; import { _ } from 'svelte-i18n';
import { selection } from './file-list/Selection';
import { get } from 'svelte/store';
import { GPXStatistics } from 'gpx'; import { GPXStatistics } from 'gpx';
import { ListRootItem } from '$lib/components/file-list/file-list'; import { ListRootItem } from './file-list/FileList';
import { fileStateCollection } from '$lib/logic/file-state.svelte';
import { selection } from '$lib/logic/selection.svelte';
let open = $derived(exportState.current !== ExportState.NONE); let open = false;
let exportOptions: Record<string, boolean> = $state({ let exportOptions: Record<string, boolean> = {
time: true, time: true,
hr: true, hr: true,
cad: true, cad: true,
atemp: true, atemp: true,
power: true, power: true,
extensions: false, extensions: true,
}); };
let hide: Record<string, boolean> = $derived.by(() => { let hide: Record<string, boolean> = {
// if (exportState.current === ExportState.NONE) {
// return {
// time: false,
// hr: false,
// cad: false,
// atemp: false,
// power: false,
// extensions: false,
// };
// } else {
// let statistics = $gpxStatistics;
// if (exportState.current === ExportState.ALL) {
// statistics = Array.from(fileStateCollection.files.values())
// .map((file) => file.statistics)
// .reduce((acc, cur) => {
// if (cur !== undefined) {
// acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
// }
// return acc;
// }, new GPXStatistics());
// }
// return {
// time: statistics.global.time.total === 0,
// hr: statistics.global.hr.count === 0,
// cad: statistics.global.cad.count === 0,
// atemp: statistics.global.atemp.count === 0,
// power: statistics.global.power.count === 0,
// extensions: Object.keys(statistics.global.extensions).length === 0,
// };
// }
return {
time: false, time: false,
hr: false, hr: false,
cad: false, cad: false,
@@ -75,21 +45,39 @@
power: false, power: false,
extensions: false, extensions: false,
}; };
});
let exclude = $derived(Object.keys(exportOptions).filter((key) => !exportOptions[key]));
$effect(() => { $: if ($exportState !== ExportState.NONE) {
if (open) { open = true;
tool.current = null; $currentTool = null;
let statistics = $gpxStatistics;
if ($exportState === ExportState.ALL) {
statistics = Array.from($fileObservers.values())
.map((file) => get(file)?.statistics)
.reduce((acc, cur) => {
if (cur !== undefined) {
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
} }
}); return acc;
}, new GPXStatistics());
}
hide.time = statistics.global.time.total === 0;
hide.hr = statistics.global.hr.count === 0;
hide.cad = statistics.global.cad.count === 0;
hide.atemp = statistics.global.atemp.count === 0;
hide.power = statistics.global.power.count === 0;
hide.extensions = Object.keys(statistics.global.extensions).length === 0;
}
$: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]);
</script> </script>
<Dialog.Root <Dialog.Root
bind:open bind:open
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
if (!isOpen) { if (!isOpen) {
exportState.current = ExportState.NONE; $exportState = ExportState.NONE;
} }
}} }}
> >
@@ -103,32 +91,32 @@
> >
<span>⚠️</span> <span>⚠️</span>
<span class="max-w-[80%] text-sm"> <span class="max-w-[80%] text-sm">
{i18n._('menu.support_message')} {$_('menu.support_message')}
</span> </span>
</div> </div>
<div class="w-full flex flex-row flex-wrap gap-2"> <div class="w-full flex flex-row flex-wrap gap-2">
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank"> <Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
{i18n._('menu.support_button')} {$_('menu.support_button')}
<span class="ml-2">🙏</span> <span class="ml-2">🙏</span>
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
class="grow" class="grow"
onclick={() => { on:click={() => {
if (exportState.current === ExportState.SELECTION) { if ($exportState === ExportState.SELECTION) {
exportSelectedFiles(exclude); exportSelectedFiles(exclude);
} else if (exportState.current === ExportState.ALL) { } else if ($exportState === ExportState.ALL) {
exportAllFiles(exclude); exportAllFiles(exclude);
} }
open = false; open = false;
exportState.current = ExportState.NONE; $exportState = ExportState.NONE;
}} }}
> >
<Download size="16" class="mr-1" /> <Download size="16" class="mr-1" />
{#if fileStateCollection.files.size === 1 || (exportState.current === ExportState.SELECTION && selection.value.size === 1)} {#if $fileObservers.size === 1 || ($exportState === ExportState.SELECTION && $selection.size === 1)}
{i18n._('menu.download_file')} {$_('menu.download_file')}
{:else} {:else}
{i18n._('menu.download_files')} {$_('menu.download_files')}
{/if} {/if}
</Button> </Button>
</div> </div>
@@ -144,7 +132,7 @@
<Separator /> <Separator />
</div> </div>
<Label class="shrink-0"> <Label class="shrink-0">
{i18n._('menu.export_options')} {$_('menu.export_options')}
</Label> </Label>
<div class="grow"> <div class="grow">
<Separator /> <Separator />
@@ -155,7 +143,7 @@
<Checkbox id="export-time" bind:checked={exportOptions.time} /> <Checkbox id="export-time" bind:checked={exportOptions.time} />
<Label for="export-time" class="flex flex-row items-center gap-1"> <Label for="export-time" class="flex flex-row items-center gap-1">
<Zap size="16" /> <Zap size="16" />
{i18n._('quantities.time')} {$_('quantities.time')}
</Label> </Label>
</div> </div>
<div <div
@@ -164,35 +152,35 @@
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} /> <Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
<Label for="export-extensions" class="flex flex-row items-center gap-1"> <Label for="export-extensions" class="flex flex-row items-center gap-1">
<Earth size="16" /> <Earth size="16" />
{i18n._('quantities.osm_extensions')} {$_('quantities.osm_extensions')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}"> <div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} /> <Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
<Label for="export-heartrate" class="flex flex-row items-center gap-1"> <Label for="export-heartrate" class="flex flex-row items-center gap-1">
<HeartPulse size="16" /> <HeartPulse size="16" />
{i18n._('quantities.heartrate')} {$_('quantities.heartrate')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}"> <div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} /> <Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
<Label for="export-cadence" class="flex flex-row items-center gap-1"> <Label for="export-cadence" class="flex flex-row items-center gap-1">
<Orbit size="16" /> <Orbit size="16" />
{i18n._('quantities.cadence')} {$_('quantities.cadence')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}"> <div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} /> <Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
<Label for="export-temperature" class="flex flex-row items-center gap-1"> <Label for="export-temperature" class="flex flex-row items-center gap-1">
<Thermometer size="16" /> <Thermometer size="16" />
{i18n._('quantities.temperature')} {$_('quantities.temperature')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}"> <div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
<Checkbox id="export-power" bind:checked={exportOptions.power} /> <Checkbox id="export-power" bind:checked={exportOptions.power} />
<Label for="export-power" class="flex flex-row items-center gap-1"> <Label for="export-power" class="flex flex-row items-center gap-1">
<SquareActivity size="16" /> <SquareActivity size="16" />
{i18n._('quantities.power')} {$_('quantities.power')}
</Label> </Label>
</div> </div>
</div> </div>

View File

@@ -2,8 +2,8 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import LanguageSelect from '$lib/components/LanguageSelect.svelte'; import LanguageSelect from '$lib/components/LanguageSelect.svelte';
import Logo from '$lib/components/Logo.svelte'; import Logo from '$lib/components/Logo.svelte';
import { AtSign, BookOpenText, Heart, Home, Map } from '@lucide/svelte'; import { AtSign, BookOpenText, Heart, Home, Map } from 'lucide-svelte';
import { i18n } from '$lib/i18n.svelte'; import { _, locale } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
</script> </script>
@@ -24,42 +24,42 @@
</div> </div>
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6"> <div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="flex flex-col items-start gap-1"> <div class="flex flex-col items-start gap-1">
<span class="font-semibold">{i18n._('homepage.website')}</span> <span class="font-semibold">{$_('homepage.website')}</span>
<Button <Button
variant="link" variant="link"
class="h-6 px-0 text-muted-foreground" class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/')} href={getURLForLanguage($locale, '/')}
> >
<Home size="16" /> <Home size="16" class="mr-1" />
{i18n._('homepage.home')} {$_('homepage.home')}
</Button> </Button>
<Button <Button
variant="link" variant="link"
class="h-6 px-0 text-muted-foreground" class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/app')} href={getURLForLanguage($locale, '/app')}
> >
<Map size="16" /> <Map size="16" class="mr-1" />
{i18n._('homepage.app')} {$_('homepage.app')}
</Button> </Button>
<Button <Button
variant="link" variant="link"
class="h-6 px-0 text-muted-foreground" class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/help')} href={getURLForLanguage($locale, '/help')}
> >
<BookOpenText size="16" /> <BookOpenText size="16" class="mr-1" />
{i18n._('menu.help')} {$_('menu.help')}
</Button> </Button>
</div> </div>
<div class="flex flex-col items-start gap-1" id="contact"> <div class="flex flex-col items-start gap-1" id="contact">
<span class="font-semibold">{i18n._('homepage.contact')}</span> <span class="font-semibold">{$_('homepage.contact')}</span>
<Button <Button
variant="link" variant="link"
class="h-6 px-0 text-muted-foreground" class="h-6 px-0 text-muted-foreground"
href="https://www.reddit.com/r/gpxstudio/" href="https://www.reddit.com/r/gpxstudio/"
target="_blank" target="_blank"
> >
<Logo company="reddit" class="h-4 fill-muted-foreground" /> <Logo company="reddit" class="h-4 mr-1 fill-muted-foreground" />
{i18n._('homepage.reddit')} {$_('homepage.reddit')}
</Button> </Button>
<Button <Button
variant="link" variant="link"
@@ -67,8 +67,8 @@
href="https://facebook.com/gpx.studio" href="https://facebook.com/gpx.studio"
target="_blank" target="_blank"
> >
<Logo company="facebook" class="h-4 fill-muted-foreground" /> <Logo company="facebook" class="h-4 mr-1 fill-muted-foreground" />
{i18n._('homepage.facebook')} {$_('homepage.facebook')}
</Button> </Button>
<Button <Button
variant="link" variant="link"
@@ -76,8 +76,8 @@
href="https://x.com/gpxstudio" href="https://x.com/gpxstudio"
target="_blank" target="_blank"
> >
<Logo company="x" class="h-4 fill-muted-foreground" /> <Logo company="x" class="h-4 mr-1 fill-muted-foreground" />
{i18n._('homepage.x')} {$_('homepage.x')}
</Button> </Button>
<Button <Button
variant="link" variant="link"
@@ -85,20 +85,20 @@
href="mailto:hello@gpx.studio" href="mailto:hello@gpx.studio"
target="_blank" target="_blank"
> >
<AtSign size="16" /> <AtSign size="16" class="mr-1" />
{i18n._('homepage.email')} {$_('homepage.email')}
</Button> </Button>
</div> </div>
<div class="flex flex-col items-start gap-1"> <div class="flex flex-col items-start gap-1">
<span class="font-semibold">{i18n._('homepage.contribute')}</span> <span class="font-semibold">{$_('homepage.contribute')}</span>
<Button <Button
variant="link" variant="link"
class="h-6 px-0 text-muted-foreground" class="h-6 px-0 text-muted-foreground"
href="https://ko-fi.com/gpxstudio" href="https://ko-fi.com/gpxstudio"
target="_blank" target="_blank"
> >
<Heart size="16" /> <Heart size="16" class="mr-1" />
{i18n._('menu.donate')} {$_('menu.donate')}
</Button> </Button>
<Button <Button
variant="link" variant="link"
@@ -106,8 +106,8 @@
href="https://crowdin.com/project/gpxstudio" href="https://crowdin.com/project/gpxstudio"
target="_blank" target="_blank"
> >
<Logo company="crowdin" class="h-4 fill-muted-foreground" /> <Logo company="crowdin" class="h-4 mr-1 fill-muted-foreground" />
{i18n._('homepage.crowdin')} {$_('homepage.crowdin')}
</Button> </Button>
<Button <Button
variant="link" variant="link"
@@ -115,8 +115,8 @@
href="https://github.com/gpxstudio/gpx.studio" href="https://github.com/gpxstudio/gpx.studio"
target="_blank" target="_blank"
> >
<Logo company="github" class="h-4 fill-muted-foreground" /> <Logo company="github" class="h-4 mr-1 fill-muted-foreground" />
{i18n._('homepage.github')} {$_('homepage.github')}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -3,9 +3,9 @@
import Tooltip from '$lib/components/Tooltip.svelte'; import Tooltip from '$lib/components/Tooltip.svelte';
import WithUnits from '$lib/components/WithUnits.svelte'; import WithUnits from '$lib/components/WithUnits.svelte';
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte'; import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from 'lucide-svelte';
import { i18n } from '$lib/i18n.svelte'; import { _ } from 'svelte-i18n';
import type { GPXStatistics } from 'gpx'; import type { GPXStatistics } from 'gpx';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
import { settings } from '$lib/db'; import { settings } from '$lib/db';
@@ -36,13 +36,13 @@
? 'flex-col justify-center' ? 'flex-col justify-center'
: 'flex-row w-full justify-between'} gap-4 p-0" : 'flex-row w-full justify-between'} gap-4 p-0"
> >
<Tooltip label={i18n._('quantities.distance')}> <Tooltip label={$_('quantities.distance')}>
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" /> <Ruler size="16" class="mr-1" />
<WithUnits value={statistics.global.distance.total} type="distance" /> <WithUnits value={statistics.global.distance.total} type="distance" />
</span> </span>
</Tooltip> </Tooltip>
<Tooltip label={i18n._('quantities.elevation_gain_loss')}> <Tooltip label={$_('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" /> <MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.global.elevation.gain} type="elevation" /> <WithUnits value={statistics.global.elevation.gain} type="elevation" />
@@ -54,10 +54,8 @@
<Tooltip <Tooltip
class={orientation === 'horizontal' ? 'hidden xs:block' : ''} class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
label="{$velocityUnits === 'speed' label="{$velocityUnits === 'speed'
? i18n._('quantities.speed') ? $_('quantities.speed')
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._( : $_('quantities.pace')} ({$_('quantities.moving')} / {$_('quantities.total')})"
'quantities.total'
)})"
> >
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Zap size="16" class="mr-1" /> <Zap size="16" class="mr-1" />
@@ -74,7 +72,7 @@
{#if panelSize > 160 || orientation === 'horizontal'} {#if panelSize > 160 || orientation === 'horizontal'}
<Tooltip <Tooltip
class={orientation === 'horizontal' ? 'hidden md:block' : ''} class={orientation === 'horizontal' ? 'hidden md:block' : ''}
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._( label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_(
'quantities.total' 'quantities.total'
)})" )})"
> >

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { CircleHelp } from '@lucide/svelte'; import { CircleHelp } from 'lucide-svelte';
import { i18n } from '$lib/i18n.svelte'; import { _ } from 'svelte-i18n';
export let link: string | undefined = undefined; export let link: string | undefined = undefined;
</script> </script>
@@ -13,7 +13,7 @@
<slot /> <slot />
{#if link} {#if link}
<a href={link} target="_blank" class="text-sm text-link hover:underline"> <a href={link} target="_blank" class="text-sm text-link hover:underline">
{i18n._('menu.more')} {$_('menu.more')}
</a> </a>
{/if} {/if}
</div> </div>

View File

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

View File

@@ -1,20 +1,22 @@
<script lang="ts"> <script lang="ts">
import { base } from '$app/paths'; import { base } from '$app/paths';
import { mode } from 'mode-watcher'; import { mode, systemPrefersMode } from 'mode-watcher';
export let iconOnly = false; export let iconOnly = false;
export let company = 'gpx.studio'; export let company = 'gpx.studio';
$: effectiveMode = $mode ?? $systemPrefersMode ?? 'light';
</script> </script>
{#if company === 'gpx.studio'} {#if company === 'gpx.studio'}
<img <img
src="{base}/{iconOnly ? 'icon' : 'logo'}{mode.current === 'dark' ? '-dark' : ''}.svg" src="{base}/{iconOnly ? 'icon' : 'logo'}{effectiveMode === 'dark' ? '-dark' : ''}.svg"
alt="Logo of gpx.studio." alt="Logo of gpx.studio."
{...$$restProps} {...$$restProps}
/> />
{:else if company === 'mapbox'} {:else if company === 'mapbox'}
<img <img
src="{base}/mapbox-logo-{mode.current === 'dark' ? 'white' : 'black'}.svg" src="{base}/mapbox-logo-{effectiveMode === 'dark' ? 'white' : 'black'}.svg"
alt="Logo of Mapbox." alt="Logo of Mapbox."
{...$$restProps} {...$$restProps}
/> />

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
import { TrackPoint, Waypoint } from 'gpx';
import mapboxgl from 'mapbox-gl';
import { tick } from 'svelte';
import { get, writable, type Writable } from 'svelte/store';
import MapPopupComponent from './MapPopup.svelte';
export type PopupItem<T = Waypoint | TrackPoint | any> = {
item: T;
fileId?: string;
hide?: () => void;
};
export class MapPopup {
map: mapboxgl.Map;
popup: mapboxgl.Popup;
item: Writable<PopupItem | null> = writable(null);
maybeHideBinded = this.maybeHide.bind(this);
constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
this.map = map;
this.popup = new mapboxgl.Popup(options);
let component = new MapPopupComponent({
target: document.body,
props: {
item: this.item,
},
});
tick().then(() => this.popup.setDOMContent(component.container));
}
setItem(item: PopupItem | null) {
if (item) item.hide = () => this.hide();
this.item.set(item);
if (item === null) {
this.hide();
} else {
tick().then(() => this.show());
}
}
show() {
const i = get(this.item);
if (i === null) {
this.hide();
return;
}
this.popup.setLngLat(this.getCoordinates()).addTo(this.map);
this.map.on('mousemove', this.maybeHideBinded);
}
maybeHide(e: mapboxgl.MapMouseEvent) {
const i = get(this.item);
if (i === null) {
this.hide();
return;
}
if (this.map.project(this.getCoordinates()).dist(this.map.project(e.lngLat)) > 60) {
this.hide();
}
}
hide() {
this.popup.remove();
this.map.off('mousemove', this.maybeHideBinded);
}
remove() {
this.popup.remove();
}
getCoordinates() {
const i = get(this.item);
if (i === null) {
return new mapboxgl.LngLat(0, 0);
}
return i.item instanceof Waypoint || i.item instanceof TrackPoint
? i.item.getCoordinates()
: new mapboxgl.LngLat(i.item.lon, i.item.lat);
}
}

View File

@@ -43,43 +43,40 @@
BookOpenText, BookOpenText,
ChartArea, ChartArea,
Maximize, Maximize,
} from '@lucide/svelte'; } from 'lucide-svelte';
import { map } from '$lib/components/map/utils.svelte';
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 {
// triggerFileInput,
// createFile,
// loadFiles,
// updateSelectionFromKey,
// allHidden,
// } from '$lib/stores';
// import { canUndo, canRedo, fileActions, fileObservers, settings } from '$lib/db';
import { anySelectedLayer } from '$lib/components/map/layer-control/utils.svelte';
import { defaultOverlays } from '$lib/assets/layers';
// import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte';
import { import {
allowedPastes, map,
ListFileItem, triggerFileInput,
ListTrackItem, createFile,
} from '$lib/components/file-list/file-list'; loadFiles,
import Export from '$lib/components/export/Export.svelte'; updateSelectionFromKey,
import { mode, setMode } from 'mode-watcher'; allHidden,
import { i18n } from '$lib/i18n.svelte'; editMetadata,
editStyle,
exportState,
ExportState,
centerMapOnSelection,
} from '$lib/stores';
import {
copied,
copySelection,
cutSelection,
pasteSelection,
selectAll,
selection,
} from '$lib/components/file-list/Selection';
import { derived } from 'svelte/store';
import { canUndo, canRedo, dbUtils, fileObservers, settings } from '$lib/db';
import { anySelectedLayer } from '$lib/components/layer-control/utils';
import { defaultOverlays } from '$lib/assets/layers';
import LayerControlSettings from '$lib/components/layer-control/LayerControlSettings.svelte';
import { allowedPastes, ListFileItem, ListTrackItem } from '$lib/components/file-list/FileList';
import Export from '$lib/components/Export.svelte';
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
import { _, locale } from 'svelte-i18n';
import { languages } from '$lib/languages'; import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { settings } from '$lib/logic/settings.svelte';
import {
createFile,
fileActions,
loadFiles,
pasteSelection,
triggerFileInput,
} from '$lib/logic/file-actions.svelte';
import { fileStateCollection } from '$lib/logic/file-state.svelte';
import { fileActionManager } from '$lib/logic/file-action-manager.svelte';
import { selection } from '$lib/logic/selection.svelte';
const { const {
distanceUnits, distanceUnits,
@@ -97,363 +94,351 @@
routing, routing,
} = settings; } = settings;
let undoDisabled = derived(canUndo, ($canUndo) => !$canUndo);
let redoDisabled = derived(canRedo, ($canRedo) => !$canRedo);
function switchBasemaps() { function switchBasemaps() {
[currentBasemap.value, previousBasemap.value] = [ [$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
previousBasemap.value,
currentBasemap.value,
];
} }
function toggleOverlays() { function toggleOverlays() {
if (currentOverlays.value && anySelectedLayer(currentOverlays.value)) { if (anySelectedLayer($currentOverlays)) {
previousOverlays.value = JSON.parse(JSON.stringify(currentOverlays.value)); [$currentOverlays, $previousOverlays] = [defaultOverlays, $currentOverlays];
currentOverlays.value = defaultOverlays;
} else { } else {
currentOverlays.value = JSON.parse(JSON.stringify(previousOverlays.value)); [$currentOverlays, $previousOverlays] = [$previousOverlays, defaultOverlays];
previousOverlays.value = defaultOverlays;
} }
} }
let layerSettingsOpen = $state(false); function toggle3D() {
if ($map) {
if ($map.getPitch() === 0) {
$map.easeTo({ pitch: 70 });
} else {
$map.easeTo({ pitch: 0 });
}
}
}
let layerSettingsOpen = false;
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
</script> </script>
<div class="absolute md:top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none"> <div class="absolute md:top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none">
<div <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" 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={getURLForLanguage($locale, '/')} 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 md:hidden" iconOnly={true} width="16" />
<Logo class="h-5 mt-0.5 mx-2 hidden md:block" width="96" /> <Logo class="h-5 mt-0.5 mx-2 hidden md:block" width="96" />
</a> </a>
<Menubar.Root class="border-none shadow-none h-fit p-0"> <Menubar.Root class="border-none h-fit p-0">
<Menubar.Menu> <Menubar.Menu>
<Menubar.Trigger aria-label={i18n._('gpx.file')}> <Menubar.Trigger aria-label={$_('gpx.file')}>
<File size="18" class="md:hidden" /> <File size="18" class="md:hidden" />
<span class="hidden md:block">{i18n._('gpx.file')}</span> <span class="hidden md:block">{$_('gpx.file')}</span>
</Menubar.Trigger> </Menubar.Trigger>
<Menubar.Content class="border-none"> <Menubar.Content class="border-none">
<Menubar.Item onclick={createFile}> <Menubar.Item on:click={createFile}>
<Plus size="16" class="mr-1" /> <Plus size="16" class="mr-1" />
{i18n._('menu.new')} {$_('menu.new')}
<Shortcut key="+" ctrl={true} /> <Shortcut key="+" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item onclick={triggerFileInput}> <Menubar.Item on:click={triggerFileInput}>
<FolderOpen size="16" class="mr-1" /> <FolderOpen size="16" class="mr-1" />
{i18n._('menu.open')} {$_('menu.open')}
<Shortcut key="O" ctrl={true} /> <Shortcut key="O" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
onclick={fileActions.duplicateSelection} on:click={dbUtils.duplicateSelection}
disabled={selection.value.size == 0} disabled={$selection.size == 0}
> >
<Copy size="16" class="mr-1" /> <Copy size="16" class="mr-1" />
{i18n._('menu.duplicate')} {$_('menu.duplicate')}
<Shortcut key="D" ctrl={true} /> <Shortcut key="D" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
onclick={fileActions.deleteSelectedFiles} on:click={dbUtils.deleteSelectedFiles}
disabled={selection.value.size == 0} disabled={$selection.size == 0}
> >
<FileX size="16" class="mr-1" /> <FileX size="16" class="mr-1" />
{i18n._('menu.close')} {$_('menu.close')}
<Shortcut key="⌫" ctrl={true} /> <Shortcut key="⌫" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item
onclick={fileActions.deleteAllFiles} on:click={dbUtils.deleteAllFiles}
disabled={fileStateCollection.size == 0} disabled={$fileObservers.size == 0}
> >
<FileX size="16" class="mr-1" /> <FileX size="16" class="mr-1" />
{i18n._('menu.close_all')} {$_('menu.close_all')}
<Shortcut key="⌫" ctrl={true} shift={true} /> <Shortcut key="⌫" ctrl={true} shift={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
onclick={() => (exportState.current = ExportState.SELECTION)} on:click={() => ($exportState = ExportState.SELECTION)}
disabled={selection.value.size == 0} disabled={$selection.size == 0}
> >
<Download size="16" class="mr-1" /> <Download size="16" class="mr-1" />
{i18n._('menu.export')} {$_('menu.export')}
<Shortcut key="S" ctrl={true} /> <Shortcut key="S" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item
onclick={() => (exportState.current = ExportState.ALL)} on:click={() => ($exportState = ExportState.ALL)}
disabled={fileStateCollection.size == 0} disabled={$fileObservers.size == 0}
> >
<Download size="16" class="mr-1" /> <Download size="16" class="mr-1" />
{i18n._('menu.export_all')} {$_('menu.export_all')}
<Shortcut key="S" ctrl={true} shift={true} /> <Shortcut key="S" ctrl={true} shift={true} />
</Menubar.Item> </Menubar.Item>
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
<Menubar.Menu> <Menubar.Menu>
<Menubar.Trigger aria-label={i18n._('menu.edit')}> <Menubar.Trigger aria-label={$_('menu.edit')}>
<FilePen size="18" class="md:hidden" /> <FilePen size="18" class="md:hidden" />
<span class="hidden md:block">{i18n._('menu.edit')}</span> <span class="hidden md:block">{$_('menu.edit')}</span>
</Menubar.Trigger> </Menubar.Trigger>
<Menubar.Content class="border-none"> <Menubar.Content class="border-none">
<Menubar.Item <Menubar.Item on:click={dbUtils.undo} disabled={$undoDisabled}>
onclick={fileActionManager.undo}
disabled={!fileActionManager.canUndo}
>
<Undo2 size="16" class="mr-1" /> <Undo2 size="16" class="mr-1" />
{i18n._('menu.undo')} {$_('menu.undo')}
<Shortcut key="Z" ctrl={true} /> <Shortcut key="Z" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item on:click={dbUtils.redo} disabled={$redoDisabled}>
onclick={fileActionManager.redo}
disabled={!fileActionManager.canRedo}
>
<Redo2 size="16" class="mr-1" /> <Redo2 size="16" class="mr-1" />
{i18n._('menu.redo')} {$_('menu.redo')}
<Shortcut key="Z" ctrl={true} shift={true} /> <Shortcut key="Z" ctrl={true} shift={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
disabled={selection.value.size !== 1 || disabled={$selection.size !== 1 ||
!selection.value !$selection
.getSelected() .getSelected()
.every( .every(
(item) => (item) =>
item instanceof ListFileItem || item instanceof ListFileItem ||
item instanceof ListTrackItem item instanceof ListTrackItem
)} )}
onclick={() => (editMetadata.current = true)} on:click={() => ($editMetadata = true)}
> >
<Info size="16" class="mr-1" /> <Info size="16" class="mr-1" />
{i18n._('menu.metadata.button')} {$_('menu.metadata.button')}
<Shortcut key="I" ctrl={true} /> <Shortcut key="I" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item
disabled={selection.value.size === 0 || disabled={$selection.size === 0 ||
!selection.value !$selection
.getSelected() .getSelected()
.every( .every(
(item) => (item) =>
item instanceof ListFileItem || item instanceof ListFileItem ||
item instanceof ListTrackItem item instanceof ListTrackItem
)} )}
onclick={() => (editStyle.current = true)} on:click={() => ($editStyle = true)}
> >
<PaintBucket size="16" class="mr-1" /> <PaintBucket size="16" class="mr-1" />
{i18n._('menu.style.button')} {$_('menu.style.button')}
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item
onclick={() => { on:click={() => {
// if ($allHidden) { if ($allHidden) {
// fileActions.setHiddenToSelection(false); dbUtils.setHiddenToSelection(false);
// } else { } else {
// fileActions.setHiddenToSelection(true); dbUtils.setHiddenToSelection(true);
// } }
}} }}
disabled={selection.value.size == 0} disabled={$selection.size == 0}
> >
<!-- {#if $allHidden} {#if $allHidden}
<Eye size="16" class="mr-1" /> <Eye size="16" class="mr-1" />
{i18n._('menu.unhide')} {$_('menu.unhide')}
{:else} {:else}
<EyeOff size="16" class="mr-1" /> <EyeOff size="16" class="mr-1" />
{i18n._('menu.hide')} {$_('menu.hide')}
{/if} --> {/if}
<Shortcut key="H" ctrl={true} /> <Shortcut key="H" ctrl={true} />
</Menubar.Item> </Menubar.Item>
{#if treeFileView.value} {#if $treeFileView}
{#if selection.value {#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
.getSelected()
.some((item) => item instanceof ListFileItem)}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
onclick={() => on:click={() =>
fileActions.addNewTrack( dbUtils.addNewTrack($selection.getSelected()[0].getFileId())}
selection.value.getSelected()[0].getFileId() disabled={$selection.size !== 1}
)}
disabled={selection.value.size !== 1}
> >
<Plus size="16" class="mr-1" /> <Plus size="16" class="mr-1" />
{i18n._('menu.new_track')} {$_('menu.new_track')}
</Menubar.Item> </Menubar.Item>
{:else if selection.value {:else if $selection
.getSelected() .getSelected()
.some((item) => item instanceof ListTrackItem)} .some((item) => item instanceof ListTrackItem)}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
onclick={() => { on:click={() => {
let item = selection.value.getSelected()[0]; let item = $selection.getSelected()[0];
fileActions.addNewSegment( dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex());
item.getFileId(),
item.getTrackIndex()
);
}} }}
disabled={selection.value.size !== 1} disabled={$selection.size !== 1}
> >
<Plus size="16" class="mr-1" /> <Plus size="16" class="mr-1" />
{i18n._('menu.new_segment')} {$_('menu.new_segment')}
</Menubar.Item> </Menubar.Item>
{/if} {/if}
{/if} {/if}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item on:click={selectAll} disabled={$fileObservers.size == 0}>
onclick={selection.selectAll}
disabled={fileStateCollection.size == 0}
>
<FileStack size="16" class="mr-1" /> <FileStack size="16" class="mr-1" />
{i18n._('menu.select_all')} {$_('menu.select_all')}
<Shortcut key="A" ctrl={true} /> <Shortcut key="A" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item
onclick={() => { on:click={() => {
if (selection.value.size > 0) { if ($selection.size > 0) {
// centerMapOnSelection(); centerMapOnSelection();
} }
}} }}
> >
<Maximize size="16" class="mr-1" /> <Maximize size="16" class="mr-1" />
{i18n._('menu.center')} {$_('menu.center')}
<Shortcut key="⏎" ctrl={true} /> <Shortcut key="⏎" ctrl={true} />
</Menubar.Item> </Menubar.Item>
{#if treeFileView.value} {#if $treeFileView}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item on:click={copySelection} disabled={$selection.size === 0}>
onclick={selection.copySelection}
disabled={selection.value.size === 0}
>
<ClipboardCopy size="16" class="mr-1" /> <ClipboardCopy size="16" class="mr-1" />
{i18n._('menu.copy')} {$_('menu.copy')}
<Shortcut key="C" ctrl={true} /> <Shortcut key="C" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item on:click={cutSelection} disabled={$selection.size === 0}>
onclick={selection.cutSelection}
disabled={selection.value.size === 0}
>
<Scissors size="16" class="mr-1" /> <Scissors size="16" class="mr-1" />
{i18n._('menu.cut')} {$_('menu.cut')}
<Shortcut key="X" ctrl={true} /> <Shortcut key="X" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item
disabled={selection.copied === undefined || disabled={$copied === undefined ||
selection.copied.length === 0 || $copied.length === 0 ||
(selection.value.size > 0 && ($selection.size > 0 &&
!allowedPastes[selection.copied[0].level].includes( !allowedPastes[$copied[0].level].includes(
selection.value.getSelected().pop()?.level $selection.getSelected().pop()?.level
))} ))}
onclick={pasteSelection} on:click={pasteSelection}
> >
<ClipboardPaste size="16" class="mr-1" /> <ClipboardPaste size="16" class="mr-1" />
{i18n._('menu.paste')} {$_('menu.paste')}
<Shortcut key="V" ctrl={true} /> <Shortcut key="V" ctrl={true} />
</Menubar.Item> </Menubar.Item>
{/if} {/if}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
onclick={fileActions.deleteSelection} on:click={dbUtils.deleteSelection}
disabled={selection.value.size == 0} disabled={$selection.size == 0}
> >
<Trash2 size="16" class="mr-1" /> <Trash2 size="16" class="mr-1" />
{i18n._('menu.delete')} {$_('menu.delete')}
<Shortcut key="⌫" ctrl={true} /> <Shortcut key="⌫" ctrl={true} />
</Menubar.Item> </Menubar.Item>
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
<Menubar.Menu> <Menubar.Menu>
<Menubar.Trigger aria-label={i18n._('menu.view')}> <Menubar.Trigger aria-label={$_('menu.view')}>
<View size="18" class="md:hidden" /> <View size="18" class="md:hidden" />
<span class="hidden md:block">{i18n._('menu.view')}</span> <span class="hidden md:block">{$_('menu.view')}</span>
</Menubar.Trigger> </Menubar.Trigger>
<Menubar.Content class="border-none"> <Menubar.Content class="border-none">
<Menubar.CheckboxItem bind:checked={elevationProfile.value}> <Menubar.CheckboxItem bind:checked={$elevationProfile}>
<ChartArea size="16" class="mr-1" /> <ChartArea size="16" class="mr-1" />
{i18n._('menu.elevation_profile')} {$_('menu.elevation_profile')}
<Shortcut key="P" ctrl={true} /> <Shortcut key="P" ctrl={true} />
</Menubar.CheckboxItem> </Menubar.CheckboxItem>
<Menubar.CheckboxItem bind:checked={treeFileView.value}> <Menubar.CheckboxItem bind:checked={$treeFileView}>
<ListTree size="16" class="mr-1" /> <ListTree size="16" class="mr-1" />
{i18n._('menu.tree_file_view')} {$_('menu.tree_file_view')}
<Shortcut key="L" ctrl={true} /> <Shortcut key="L" ctrl={true} />
</Menubar.CheckboxItem> </Menubar.CheckboxItem>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item inset onclick={switchBasemaps}> <Menubar.Item inset on:click={switchBasemaps}>
<Map size="16" class="mr-1" />{i18n._('menu.switch_basemap')}<Shortcut <Map size="16" class="mr-1" />{$_('menu.switch_basemap')}<Shortcut
key="F1" key="F1"
/> />
</Menubar.Item> </Menubar.Item>
<Menubar.Item inset onclick={toggleOverlays}> <Menubar.Item inset on:click={toggleOverlays}>
<Layers2 size="16" class="mr-1" />{i18n._('menu.toggle_overlays')}<Shortcut <Layers2 size="16" class="mr-1" />{$_('menu.toggle_overlays')}<Shortcut
key="F2" key="F2"
/> />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.CheckboxItem bind:checked={distanceMarkers.value}> <Menubar.CheckboxItem bind:checked={$distanceMarkers}>
<Coins size="16" class="mr-1" />{i18n._('menu.distance_markers')}<Shortcut <Coins size="16" class="mr-1" />{$_('menu.distance_markers')}<Shortcut
key="F3" key="F3"
/> />
</Menubar.CheckboxItem> </Menubar.CheckboxItem>
<Menubar.CheckboxItem bind:checked={directionMarkers.value}> <Menubar.CheckboxItem bind:checked={$directionMarkers}>
<Milestone size="16" class="mr-1" />{i18n._( <Milestone size="16" class="mr-1" />{$_('menu.direction_markers')}<Shortcut
'menu.direction_markers' key="F4"
)}<Shortcut key="F4" /> />
</Menubar.CheckboxItem> </Menubar.CheckboxItem>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item inset onclick={map.toggle3D}> <Menubar.Item inset on:click={toggle3D}>
<Box size="16" class="mr-1" /> <Box size="16" class="mr-1" />
{i18n._('menu.toggle_3d')} {$_('menu.toggle_3d')}
<Shortcut key="{i18n._('menu.ctrl')}+{i18n._('menu.drag')}" /> <Shortcut key="{$_('menu.ctrl')}+{$_('menu.drag')}" />
</Menubar.Item> </Menubar.Item>
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
<Menubar.Menu> <Menubar.Menu>
<Menubar.Trigger aria-label={i18n._('menu.settings')}> <Menubar.Trigger aria-label={$_('menu.settings')}>
<Settings size="18" class="md:hidden" /> <Settings size="18" class="md:hidden" />
<span class="hidden md:block"> <span class="hidden md:block">
{i18n._('menu.settings')} {$_('menu.settings')}
</span> </span>
</Menubar.Trigger> </Menubar.Trigger>
<Menubar.Content class="border-none"> <Menubar.Content class="border-none">
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<Ruler size="16" class="mr-1" />{i18n._('menu.distance_units')} <Ruler size="16" class="mr-1" />{$_('menu.distance_units')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={distanceUnits.value}> <Menubar.RadioGroup bind:value={$distanceUnits}>
<Menubar.RadioItem value="metric" <Menubar.RadioItem value="metric"
>{i18n._('menu.metric')}</Menubar.RadioItem >{$_('menu.metric')}</Menubar.RadioItem
> >
<Menubar.RadioItem value="imperial" <Menubar.RadioItem value="imperial"
>{i18n._('menu.imperial')}</Menubar.RadioItem >{$_('menu.imperial')}</Menubar.RadioItem
> >
<Menubar.RadioItem value="nautical" <Menubar.RadioItem value="nautical"
>{i18n._('menu.nautical')}</Menubar.RadioItem >{$_('menu.nautical')}</Menubar.RadioItem
> >
</Menubar.RadioGroup> </Menubar.RadioGroup>
</Menubar.SubContent> </Menubar.SubContent>
</Menubar.Sub> </Menubar.Sub>
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<Zap size="16" class="mr-1" />{i18n._('menu.velocity_units')} <Zap size="16" class="mr-1" />{$_('menu.velocity_units')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={velocityUnits.value}> <Menubar.RadioGroup bind:value={$velocityUnits}>
<Menubar.RadioItem value="speed" <Menubar.RadioItem value="speed"
>{i18n._('quantities.speed')}</Menubar.RadioItem >{$_('quantities.speed')}</Menubar.RadioItem
> >
<Menubar.RadioItem value="pace" <Menubar.RadioItem value="pace"
>{i18n._('quantities.pace')}</Menubar.RadioItem >{$_('quantities.pace')}</Menubar.RadioItem
> >
</Menubar.RadioGroup> </Menubar.RadioGroup>
</Menubar.SubContent> </Menubar.SubContent>
</Menubar.Sub> </Menubar.Sub>
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<Thermometer size="16" class="mr-1" />{i18n._('menu.temperature_units')} <Thermometer size="16" class="mr-1" />{$_('menu.temperature_units')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={temperatureUnits.value}> <Menubar.RadioGroup bind:value={$temperatureUnits}>
<Menubar.RadioItem value="celsius" <Menubar.RadioItem value="celsius"
>{i18n._('menu.celsius')}</Menubar.RadioItem >{$_('menu.celsius')}</Menubar.RadioItem
> >
<Menubar.RadioItem value="fahrenheit" <Menubar.RadioItem value="fahrenheit"
>{i18n._('menu.fahrenheit')}</Menubar.RadioItem >{$_('menu.fahrenheit')}</Menubar.RadioItem
> >
</Menubar.RadioGroup> </Menubar.RadioGroup>
</Menubar.SubContent> </Menubar.SubContent>
@@ -462,10 +447,10 @@
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<Languages size="16" class="mr-1" /> <Languages size="16" class="mr-1" />
{i18n._('menu.language')} {$_('menu.language')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup value={i18n.lang}> <Menubar.RadioGroup bind:value={$locale}>
{#each Object.entries(languages) as [lang, label]} {#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang, '/app')}> <a href={getURLForLanguage(lang, '/app')}>
<Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem> <Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem>
@@ -476,25 +461,24 @@
</Menubar.Sub> </Menubar.Sub>
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
{#if mode.current === 'light' || !mode.current} {#if selectedMode === 'light'}
<Sun size="16" class="mr-1" /> <Sun size="16" class="mr-1" />
{:else} {:else}
<Moon size="16" class="mr-1" /> <Moon size="16" class="mr-1" />
{/if} {/if}
{i18n._('menu.mode')} {$_('menu.mode')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup <Menubar.RadioGroup
value={mode.current ?? 'light'} bind:value={selectedMode}
onValueChange={(value) => { onValueChange={(value) => {
setMode(value as 'light' | 'dark'); setMode(value);
}} }}
> >
<Menubar.RadioItem value="light" <Menubar.RadioItem value="light"
>{i18n._('menu.light')}</Menubar.RadioItem >{$_('menu.light')}</Menubar.RadioItem
> >
<Menubar.RadioItem value="dark" <Menubar.RadioItem value="dark">{$_('menu.dark')}</Menubar.RadioItem
>{i18n._('menu.dark')}</Menubar.RadioItem
> >
</Menubar.RadioGroup> </Menubar.RadioGroup>
</Menubar.SubContent> </Menubar.SubContent>
@@ -503,22 +487,22 @@
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<PersonStanding size="16" class="mr-1" /> <PersonStanding size="16" class="mr-1" />
{i18n._('menu.street_view_source')} {$_('menu.street_view_source')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={streetViewSource.value}> <Menubar.RadioGroup bind:value={$streetViewSource}>
<Menubar.RadioItem value="mapillary" <Menubar.RadioItem value="mapillary"
>{i18n._('menu.mapillary')}</Menubar.RadioItem >{$_('menu.mapillary')}</Menubar.RadioItem
> >
<Menubar.RadioItem value="google" <Menubar.RadioItem value="google"
>{i18n._('menu.google')}</Menubar.RadioItem >{$_('menu.google')}</Menubar.RadioItem
> >
</Menubar.RadioGroup> </Menubar.RadioGroup>
</Menubar.SubContent> </Menubar.SubContent>
</Menubar.Sub> </Menubar.Sub>
<Menubar.Item onclick={() => (layerSettingsOpen = true)}> <Menubar.Item on:click={() => (layerSettingsOpen = true)}>
<Layers size="16" class="mr-1" /> <Layers size="16" class="mr-1" />
{i18n._('menu.layers')} {$_('menu.layers')}
</Menubar.Item> </Menubar.Item>
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
@@ -529,11 +513,11 @@
href="./help" href="./help"
target="_blank" target="_blank"
class="cursor-default h-fit rounded-sm px-3 py-0.5" class="cursor-default h-fit rounded-sm px-3 py-0.5"
aria-label={i18n._('menu.help')} aria-label={$_('menu.help')}
> >
<BookOpenText size="18" class="md:hidden" /> <BookOpenText size="18" class="md:hidden" />
<span class="hidden md:block"> <span class="hidden md:block">
{i18n._('menu.help')} {$_('menu.help')}
</span> </span>
</Button> </Button>
<Button <Button
@@ -541,11 +525,11 @@
href="https://ko-fi.com/gpxstudio" href="https://ko-fi.com/gpxstudio"
target="_blank" target="_blank"
class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5" class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5"
aria-label={i18n._('menu.donate')} aria-label={$_('menu.donate')}
> >
<HeartHandshake size="18" class="md:hidden" /> <HeartHandshake size="18" class="md:hidden" />
<span class="hidden md:flex flex-row items-center"> <span class="hidden md:flex flex-row items-center">
{i18n._('menu.donate')} {$_('menu.donate')}
<Heart size="16" class="ml-1" fill="rgb(var(--support))" /> <Heart size="16" class="ml-1" fill="rgb(var(--support))" />
</span> </span>
</Button> </Button>
@@ -554,7 +538,7 @@
</div> </div>
<Export /> <Export />
<!-- <LayerControlSettings bind:open={layerSettingsOpen} /> --> <LayerControlSettings bind:open={layerSettingsOpen} />
<svelte:window <svelte:window
on:keydown={(e) => { on:keydown={(e) => {
@@ -576,16 +560,16 @@
triggerFileInput(); triggerFileInput();
e.preventDefault(); e.preventDefault();
} else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
fileActions.duplicateSelection(); dbUtils.duplicateSelection();
e.preventDefault(); e.preventDefault();
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { if (!targetInput) {
selection.copySelection(); copySelection();
e.preventDefault(); e.preventDefault();
} }
} else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { if (!targetInput) {
selection.cutSelection(); cutSelection();
e.preventDefault(); e.preventDefault();
} }
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
@@ -595,61 +579,61 @@
} }
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) { } else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) { if (e.shiftKey) {
if (fileStateCollection.size > 0) { if ($fileObservers.size > 0) {
exportState.current = ExportState.ALL; $exportState = ExportState.ALL;
} }
} else if (selection.value.size > 0) { } else if ($selection.size > 0) {
exportState.current = ExportState.SELECTION; $exportState = ExportState.SELECTION;
} }
e.preventDefault(); e.preventDefault();
} else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) { } else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) { if (e.shiftKey) {
fileActionManager.redo(); dbUtils.redo();
} else { } else {
fileActionManager.undo(); dbUtils.undo();
} }
e.preventDefault(); e.preventDefault();
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) { } else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { if (!targetInput) {
if (e.shiftKey) { if (e.shiftKey) {
fileActions.deleteAllFiles(); dbUtils.deleteAllFiles();
} else { } else {
fileActions.deleteSelection(); dbUtils.deleteSelection();
} }
e.preventDefault(); e.preventDefault();
} }
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { if (!targetInput) {
selection.selectAll(); selectAll();
e.preventDefault(); e.preventDefault();
} }
} else if (e.key === 'i' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'i' && (e.metaKey || e.ctrlKey)) {
if ( if (
selection.value.size === 1 && $selection.size === 1 &&
selection.value $selection
.getSelected() .getSelected()
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem) .every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
) { ) {
editMetadata.current = true; $editMetadata = true;
} }
e.preventDefault(); e.preventDefault();
} else if (e.key === 'p' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'p' && (e.metaKey || e.ctrlKey)) {
elevationProfile.value = !elevationProfile.value; $elevationProfile = !$elevationProfile;
e.preventDefault(); e.preventDefault();
} else if (e.key === 'l' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'l' && (e.metaKey || e.ctrlKey)) {
treeFileView.value = !treeFileView.value; $treeFileView = !$treeFileView;
e.preventDefault(); e.preventDefault();
} else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) {
// if ($allHidden) { if ($allHidden) {
// fileActions.setHiddenToSelection(false); dbUtils.setHiddenToSelection(false);
// } else { } else {
// fileActions.setHiddenToSelection(true); dbUtils.setHiddenToSelection(true);
// } }
e.preventDefault(); e.preventDefault();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
// if ($selection.size > 0) { if ($selection.size > 0) {
// centerMapOnSelection(); centerMapOnSelection();
// } }
} else if (e.key === 'F1') { } else if (e.key === 'F1') {
switchBasemaps(); switchBasemaps();
e.preventDefault(); e.preventDefault();
@@ -657,13 +641,13 @@
toggleOverlays(); toggleOverlays();
e.preventDefault(); e.preventDefault();
} else if (e.key === 'F3') { } else if (e.key === 'F3') {
distanceMarkers.value = !distanceMarkers.value; $distanceMarkers = !$distanceMarkers;
e.preventDefault(); e.preventDefault();
} else if (e.key === 'F4') { } else if (e.key === 'F4') {
directionMarkers.value = !directionMarkers.value; $directionMarkers = !$directionMarkers;
e.preventDefault(); e.preventDefault();
} else if (e.key === 'F5') { } else if (e.key === 'F5') {
routing.value = !routing.value; $routing = !$routing;
e.preventDefault(); e.preventDefault();
} else if ( } else if (
e.key === 'ArrowRight' || e.key === 'ArrowRight' ||
@@ -672,7 +656,7 @@
e.key === 'ArrowUp' e.key === 'ArrowUp'
) { ) {
if (!targetInput) { if (!targetInput) {
// updateSelectionFromKey(e.key === 'ArrowRight' || e.key === 'ArrowDown', e.shiftKey); updateSelectionFromKey(e.key === 'ArrowRight' || e.key === 'ArrowDown', e.shiftKey);
e.preventDefault(); e.preventDefault();
} }
} }
@@ -687,8 +671,6 @@
/> />
<style lang="postcss"> <style lang="postcss">
@reference "../../app.css";
div :global(button) { div :global(button) {
@apply hover:bg-accent; @apply hover:bg-accent;
@apply px-3; @apply px-3;

View File

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

View File

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

View File

@@ -1,15 +1,9 @@
<script lang="ts"> <script lang="ts">
let { export let orientation: 'col' | 'row' = 'col';
orientation = 'col',
after = $bindable(), export let after: number;
minAfter = 0, export let minAfter: number = 0;
maxAfter = Number.MAX_SAFE_INTEGER, export let maxAfter: number = Number.MAX_SAFE_INTEGER;
}: {
orientation?: 'col' | 'row';
after: number;
minAfter?: number;
maxAfter?: number;
} = $props();
function handleMouseDown(event: PointerEvent) { function handleMouseDown(event: PointerEvent) {
const startX = event.clientX; const startX = event.clientX;
@@ -39,10 +33,10 @@
} }
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div
class="{orientation === 'col' class="{orientation === 'col'
? 'w-1 h-full cursor-col-resize border-l' ? 'w-1 h-full cursor-col-resize border-l'
: 'w-full h-1 cursor-row-resize border-t'} {orientation}" : 'w-full h-1 cursor-row-resize border-t'} {orientation}"
onpointerdown={handleMouseDown} on:pointerdown={handleMouseDown}
></div> />

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { isMac, isSafari } from '$lib/utils'; import { isMac, isSafari } from '$lib/utils';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { i18n } from '$lib/i18n.svelte'; import { _ } from 'svelte-i18n';
export let key: string | undefined = undefined; export let key: string | undefined = undefined;
export let shift: boolean = false; export let shift: boolean = false;
@@ -25,12 +25,12 @@
<span></span> <span></span>
{/if} {/if}
{#if ctrl} {#if ctrl}
<span>{mac && !safari ? '⌘' : i18n._('menu.ctrl') + '+'}</span> <span>{mac && !safari ? '⌘' : $_('menu.ctrl') + '+'}</span>
{/if} {/if}
{#if key} {#if key}
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span> <span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
{/if} {/if}
{#if click} {#if click}
<span>{i18n._('menu.click')}</span> <span>{$_('menu.click')}</span>
{/if} {/if}
</div> </div>

View File

@@ -5,8 +5,7 @@
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top'; export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
</script> </script>
<Tooltip.Provider> <Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger {...$$restProps} aria-label={label}> <Tooltip.Trigger {...$$restProps} aria-label={label}>
<slot /> <slot />
</Tooltip.Trigger> </Tooltip.Trigger>
@@ -16,5 +15,4 @@
<slot name="extra" /> <slot name="extra" />
</div> </div>
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider>

View File

@@ -10,26 +10,18 @@
getVelocityUnits, getVelocityUnits,
secondsToHHMMSS, secondsToHHMMSS,
} from '$lib/units'; } from '$lib/units';
import { i18n } from '$lib/i18n.svelte';
let { import { _ } from 'svelte-i18n';
value,
type, export let value: number;
showUnits = true, export let type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
decimals = undefined, export let showUnits: boolean = true;
class: className = '', export let decimals: number | undefined = undefined;
}: {
value: number;
type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
showUnits?: boolean;
decimals?: number;
class?: string;
} = $props();
const { distanceUnits, velocityUnits, temperatureUnits } = settings; const { distanceUnits, velocityUnits, temperatureUnits } = settings;
</script> </script>
<span class={className}> <span class={$$props.class}>
{#if type === 'distance'} {#if type === 'distance'}
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)} {getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getDistanceUnits($distanceUnits) : ''} {showUnits ? getDistanceUnits($distanceUnits) : ''}
@@ -46,9 +38,9 @@
{/if} {/if}
{:else if type === 'temperature'} {:else if type === 'temperature'}
{#if $temperatureUnits === 'celsius'} {#if $temperatureUnits === 'celsius'}
{value} {showUnits ? i18n._('units.celsius') : ''} {value} {showUnits ? $_('units.celsius') : ''}
{:else} {:else}
{celsiusToFahrenheit(value)} {showUnits ? i18n._('units.fahrenheit') : ''} {celsiusToFahrenheit(value)} {showUnits ? $_('units.fahrenheit') : ''}
{/if} {/if}
{:else if type === 'time'} {:else if type === 'time'}
{secondsToHHMMSS(value)} {secondsToHHMMSS(value)}

View File

@@ -1,23 +1,15 @@
<script lang="ts"> <script lang="ts">
import { setContext, type Snippet } from 'svelte'; import { setContext } from 'svelte';
import { CollapsibleTreeState } from './utils.svelte'; import { writable } from 'svelte/store';
const { export let defaultState: 'open' | 'closed' = 'open';
defaultState = 'open', export let side: 'left' | 'right' = 'right';
side = 'right', export let nohover: boolean = false;
nohover = false, export let slotInsideTrigger: boolean = true;
slotInsideTrigger = true,
children,
}: {
defaultState?: 'open' | 'closed';
side?: 'left' | 'right';
nohover?: boolean;
slotInsideTrigger?: boolean;
children: Snippet;
} = $props();
let open = $state(new CollapsibleTreeState(defaultState)); let open = writable<Record<string, boolean>>({});
setContext('collapsible-tree-default-state', defaultState);
setContext('collapsible-tree-state', open); setContext('collapsible-tree-state', open);
setContext('collapsible-tree-side', side); setContext('collapsible-tree-side', side);
setContext('collapsible-tree-nohover', nohover); setContext('collapsible-tree-nohover', nohover);
@@ -25,4 +17,4 @@
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger); setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
</script> </script>
{@render children()} <slot />

View File

@@ -1,35 +1,40 @@
<script lang="ts"> <script lang="ts">
import * as Collapsible from '$lib/components/ui/collapsible'; import * as Collapsible from '$lib/components/ui/collapsible';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { ChevronDown, ChevronLeft, ChevronRight } from '@lucide/svelte'; import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte';
import { getContext, setContext, type Snippet } from 'svelte'; import { getContext, onMount, setContext } from 'svelte';
import type { ClassValue } from 'svelte/elements'; import { get, type Writable } from 'svelte/store';
import type { CollapsibleTreeState } from './utils.svelte';
const props: { export let id: string | number;
id: string | number;
class?: ClassValue;
trigger: Snippet;
content: Snippet;
} = $props();
let state = getContext<CollapsibleTreeState>('collapsible-tree-state'); let defaultState = getContext<'open' | 'closed'>('collapsible-tree-default-state');
let open = getContext<Writable<Record<string, boolean>>>('collapsible-tree-state');
let side = getContext<'left' | 'right'>('collapsible-tree-side'); let side = getContext<'left' | 'right'>('collapsible-tree-side');
let nohover = getContext<boolean>('collapsible-tree-nohover'); let nohover = getContext<boolean>('collapsible-tree-nohover');
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger'); let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
let parentId = getContext<string>('collapsible-tree-parent-id'); let parentId = getContext<string>('collapsible-tree-parent-id');
let fullId = `${parentId}.${props.id}`; let fullId = `${parentId}.${id}`;
setContext('collapsible-tree-parent-id', fullId); setContext('collapsible-tree-parent-id', fullId);
let open = state.get(fullId); onMount(() => {
if (!get(open).hasOwnProperty(fullId)) {
open.update((value) => {
value[fullId] = defaultState === 'open';
return value;
});
}
});
export function openNode() { export function openNode() {
open.current = true; open.update((value) => {
value[fullId] = true;
return value;
});
} }
</script> </script>
<Collapsible.Root bind:open={open.current} class={props.class}> <Collapsible.Root bind:open={$open[fullId]} class={$$props.class ?? ''}>
{#if slotInsideTrigger} {#if slotInsideTrigger}
<Collapsible.Trigger class="w-full"> <Collapsible.Trigger class="w-full">
<Button <Button
@@ -41,15 +46,15 @@
: ''} pointer-events-none" : ''} pointer-events-none"
> >
{#if side === 'left'} {#if side === 'left'}
{#if open.current} {#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" /> <ChevronDown size="16" class="shrink-0" />
{:else} {:else}
<ChevronRight size="16" class="shrink-0" /> <ChevronRight size="16" class="shrink-0" />
{/if} {/if}
{/if} {/if}
{@render props.trigger()} <slot name="trigger" />
{#if side === 'right'} {#if side === 'right'}
{#if open.current} {#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" /> <ChevronDown size="16" class="shrink-0" />
{:else} {:else}
<ChevronLeft size="16" class="shrink-0" /> <ChevronLeft size="16" class="shrink-0" />
@@ -66,17 +71,17 @@
> >
{#if side === 'left'} {#if side === 'left'}
<Collapsible.Trigger> <Collapsible.Trigger>
{#if open.current} {#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" /> <ChevronDown size="16" class="shrink-0" />
{:else} {:else}
<ChevronRight size="16" class="shrink-0" /> <ChevronRight size="16" class="shrink-0" />
{/if} {/if}
</Collapsible.Trigger> </Collapsible.Trigger>
{/if} {/if}
{@render props.trigger()} <slot name="trigger" />
{#if side === 'right'} {#if side === 'right'}
<Collapsible.Trigger> <Collapsible.Trigger>
{#if open.current} {#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" /> <ChevronDown size="16" class="shrink-0" />
{:else} {:else}
<ChevronLeft size="16" class="shrink-0" /> <ChevronLeft size="16" class="shrink-0" />
@@ -87,6 +92,6 @@
{/if} {/if}
<Collapsible.Content class="ml-2"> <Collapsible.Content class="ml-2">
{@render props.content()} <slot name="content" />
</Collapsible.Content> </Collapsible.Content>
</Collapsible.Root> </Collapsible.Root>

View File

@@ -1,31 +0,0 @@
export class CollapsibleNodeState {
private _open: boolean;
constructor(defaultState: 'open' | 'closed') {
this._open = $state(defaultState === 'open');
}
get current(): boolean {
return this._open;
}
set current(value: boolean) {
this._open = value;
}
}
export class CollapsibleTreeState {
private _open: Record<string, CollapsibleNodeState> = {};
private _defaultState: 'open' | 'closed';
constructor(defaultState: 'open' | 'closed') {
this._defaultState = defaultState;
}
get(id: string): CollapsibleNodeState {
if (this._open[id] === undefined) {
this._open[id] = new CollapsibleNodeState(this._defaultState);
}
return this._open[id];
}
}

View File

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

View File

@@ -1,16 +1,14 @@
<script lang="ts"> <script lang="ts">
import type { Component } from 'svelte'; import { _ } from 'svelte-i18n';
let { module: Module }: { module: Component } = $props(); export let module;
</script> </script>
<div class="markdown flex flex-col gap-3"> <div class="markdown flex flex-col gap-3">
<Module /> <svelte:component this={module} />
</div> </div>
<style lang="postcss"> <style lang="postcss">
@reference "../../../app.css";
:global(.markdown) { :global(.markdown) {
@apply text-muted-foreground; @apply text-muted-foreground;
} }

View File

@@ -1,11 +1,6 @@
<script lang="ts"> <script lang="ts">
let { export let src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
src, export let alt: string;
alt,
}: {
src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
alt: string;
} = $props();
</script> </script>
<div class="flex flex-col items-center py-6 w-full"> <div class="flex flex-col items-center py-6 w-full">

View File

@@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; export let type: 'note' | 'warning' = 'note';
let { type = 'note', children }: { type?: 'note' | 'warning'; children: Snippet } = $props();
</script> </script>
<div <div
@@ -9,12 +7,10 @@
? 'border-link' ? 'border-link'
: 'border-destructive'} p-2 text-sm rounded-md" : 'border-destructive'} p-2 text-sm rounded-md"
> >
{@render children()} <slot />
</div> </div>
<style lang="postcss"> <style lang="postcss">
@reference "../../../app.css";
div :global(a) { div :global(a) {
@apply text-link; @apply text-link;
@apply hover:underline; @apply hover:underline;

View File

@@ -2,6 +2,7 @@ import {
File, File,
FilePen, FilePen,
View, View,
type Icon,
Settings, Settings,
Pencil, Pencil,
MapPin, MapPin,
@@ -9,12 +10,11 @@ import {
CalendarClock, CalendarClock,
Group, Group,
Ungroup, Ungroup,
Funnel, Filter,
SquareDashedMousePointer, SquareDashedMousePointer,
MountainSnow, MountainSnow,
type IconProps, } from 'lucide-svelte';
} from '@lucide/svelte'; import type { ComponentType } from 'svelte';
import type { Component } from 'svelte';
export const guides: Record<string, string[]> = { export const guides: Record<string, string[]> = {
'getting-started': [], 'getting-started': [],
@@ -37,7 +37,7 @@ export const guides: Record<string, string[]> = {
faq: [], faq: [],
}; };
export const guideIcons: Record<string, string | Component<IconProps>> = { export const guideIcons: Record<string, string | ComponentType<Icon>> = {
'getting-started': '🚀', 'getting-started': '🚀',
menu: '📂 ⚙️', menu: '📂 ⚙️',
file: File, file: File,
@@ -53,7 +53,7 @@ export const guideIcons: Record<string, string | Component<IconProps>> = {
merge: Group, merge: Group,
extract: Ungroup, extract: Ungroup,
elevation: MountainSnow, elevation: MountainSnow,
minify: Funnel, minify: Filter,
clean: SquareDashedMousePointer, clean: SquareDashedMousePointer,
'map-controls': '🗺', 'map-controls': '🗺',
gpx: '💾', gpx: '💾',

View File

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

View File

@@ -14,8 +14,8 @@
Coins, Coins,
Milestone, Milestone,
Video, Video,
} from '@lucide/svelte'; } from 'lucide-svelte';
import { i18n } from '$lib/i18n.svelte'; import { _ } from 'svelte-i18n';
import { import {
allowedEmbeddingBasemaps, allowedEmbeddingBasemaps,
getCleanedEmbeddingOptions, getCleanedEmbeddingOptions,
@@ -94,22 +94,19 @@
<Card.Root id="embedding-playground"> <Card.Root id="embedding-playground">
<Card.Header> <Card.Header>
<Card.Title>{i18n._('embedding.title')}</Card.Title> <Card.Title>{$_('embedding.title')}</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<fieldset class="flex flex-col gap-3"> <fieldset class="flex flex-col gap-3">
<Label for="token">{i18n._('embedding.mapbox_token')}</Label> <Label for="token">{$_('embedding.mapbox_token')}</Label>
<Input id="token" type="text" class="h-8" bind:value={options.token} /> <Input id="token" type="text" class="h-8" bind:value={options.token} />
<Label for="file_urls">{i18n._('embedding.file_urls')}</Label> <Label for="file_urls">{$_('embedding.file_urls')}</Label>
<Input id="file_urls" type="text" class="h-8" bind:value={files} /> <Input id="file_urls" type="text" class="h-8" bind:value={files} />
<Label for="drive_ids">{i18n._('embedding.drive_ids')}</Label> <Label for="drive_ids">{$_('embedding.drive_ids')}</Label>
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} /> <Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
<Label for="basemap">{i18n._('embedding.basemap')}</Label> <Label for="basemap">{$_('embedding.basemap')}</Label>
<Select.Root <Select.Root
selected={{ selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
value: options.basemap,
label: i18n._(`layers.label.${options.basemap}`),
}}
onSelectedChange={(selected) => { onSelectedChange={(selected) => {
if (selected?.value) { if (selected?.value) {
options.basemap = selected?.value; options.basemap = selected?.value;
@@ -121,20 +118,18 @@
</Select.Trigger> </Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll"> <Select.Content class="max-h-60 overflow-y-scroll">
{#each allowedEmbeddingBasemaps as basemap} {#each allowedEmbeddingBasemaps as basemap}
<Select.Item value={basemap} <Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
>{i18n._(`layers.label.${basemap}`)}</Select.Item
>
{/each} {/each}
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<Label for="profile">{i18n._('menu.elevation_profile')}</Label> <Label for="profile">{$_('menu.elevation_profile')}</Label>
<Checkbox id="profile" bind:checked={options.elevation.show} /> <Checkbox id="profile" bind:checked={options.elevation.show} />
</div> </div>
{#if options.elevation.show} {#if options.elevation.show}
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1"> <div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
<Label class="flex flex-row items-center gap-2"> <Label class="flex flex-row items-center gap-2">
{i18n._('embedding.height')} {$_('embedding.height')}
<Input <Input
type="number" type="number"
bind:value={options.elevation.height} bind:value={options.elevation.height}
@@ -143,10 +138,10 @@
</Label> </Label>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<span class="shrink-0"> <span class="shrink-0">
{i18n._('embedding.fill_by')} {$_('embedding.fill_by')}
</span> </span>
<Select.Root <Select.Root
selected={{ value: 'none', label: i18n._('embedding.none') }} selected={{ value: 'none', label: $_('embedding.none') }}
onSelectedChange={(selected) => { onSelectedChange={(selected) => {
let value = selected?.value; let value = selected?.value;
if (value === 'none') { if (value === 'none') {
@@ -164,55 +159,52 @@
<Select.Value /> <Select.Value />
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
<Select.Item value="slope">{i18n._('quantities.slope')}</Select.Item <Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item
> >
<Select.Item value="surface" <Select.Item value="highway">{$_('quantities.highway')}</Select.Item
>{i18n._('quantities.surface')}</Select.Item
> >
<Select.Item value="highway" <Select.Item value="none">{$_('embedding.none')}</Select.Item>
>{i18n._('quantities.highway')}</Select.Item
>
<Select.Item value="none">{i18n._('embedding.none')}</Select.Item>
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
</div> </div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<Checkbox id="controls" bind:checked={options.elevation.controls} /> <Checkbox id="controls" bind:checked={options.elevation.controls} />
<Label for="controls">{i18n._('embedding.show_controls')}</Label> <Label for="controls">{$_('embedding.show_controls')}</Label>
</div> </div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<Checkbox id="show-speed" bind:checked={options.elevation.speed} /> <Checkbox id="show-speed" bind:checked={options.elevation.speed} />
<Label for="show-speed" class="flex flex-row items-center gap-1"> <Label for="show-speed" class="flex flex-row items-center gap-1">
<Zap size="16" /> <Zap size="16" />
{i18n._('quantities.speed')} {$_('quantities.speed')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<Checkbox id="show-hr" bind:checked={options.elevation.hr} /> <Checkbox id="show-hr" bind:checked={options.elevation.hr} />
<Label for="show-hr" class="flex flex-row items-center gap-1"> <Label for="show-hr" class="flex flex-row items-center gap-1">
<HeartPulse size="16" /> <HeartPulse size="16" />
{i18n._('quantities.heartrate')} {$_('quantities.heartrate')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<Checkbox id="show-cad" bind:checked={options.elevation.cad} /> <Checkbox id="show-cad" bind:checked={options.elevation.cad} />
<Label for="show-cad" class="flex flex-row items-center gap-1"> <Label for="show-cad" class="flex flex-row items-center gap-1">
<Orbit size="16" /> <Orbit size="16" />
{i18n._('quantities.cadence')} {$_('quantities.cadence')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<Checkbox id="show-temp" bind:checked={options.elevation.temp} /> <Checkbox id="show-temp" bind:checked={options.elevation.temp} />
<Label for="show-temp" class="flex flex-row items-center gap-1"> <Label for="show-temp" class="flex flex-row items-center gap-1">
<Thermometer size="16" /> <Thermometer size="16" />
{i18n._('quantities.temperature')} {$_('quantities.temperature')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<Checkbox id="show-power" bind:checked={options.elevation.power} /> <Checkbox id="show-power" bind:checked={options.elevation.power} />
<Label for="show-power" class="flex flex-row items-center gap-1"> <Label for="show-power" class="flex flex-row items-center gap-1">
<SquareActivity size="16" /> <SquareActivity size="16" />
{i18n._('quantities.power')} {$_('quantities.power')}
</Label> </Label>
</div> </div>
</div> </div>
@@ -221,75 +213,75 @@
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} /> <Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
<Label for="distance-markers" class="flex flex-row items-center gap-1"> <Label for="distance-markers" class="flex flex-row items-center gap-1">
<Coins size="16" /> <Coins size="16" />
{i18n._('menu.distance_markers')} {$_('menu.distance_markers')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} /> <Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
<Label for="direction-markers" class="flex flex-row items-center gap-1"> <Label for="direction-markers" class="flex flex-row items-center gap-1">
<Milestone size="16" /> <Milestone size="16" />
{i18n._('menu.direction_markers')} {$_('menu.direction_markers')}
</Label> </Label>
</div> </div>
<div class="flex flex-row flex-wrap justify-between gap-3"> <div class="flex flex-row flex-wrap justify-between gap-3">
<Label class="flex flex-col items-start gap-2"> <Label class="flex flex-col items-start gap-2">
{i18n._('menu.distance_units')} {$_('menu.distance_units')}
<RadioGroup.Root bind:value={options.distanceUnits}> <RadioGroup.Root bind:value={options.distanceUnits}>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<RadioGroup.Item value="metric" id="metric" /> <RadioGroup.Item value="metric" id="metric" />
<Label for="metric">{i18n._('menu.metric')}</Label> <Label for="metric">{$_('menu.metric')}</Label>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<RadioGroup.Item value="imperial" id="imperial" /> <RadioGroup.Item value="imperial" id="imperial" />
<Label for="imperial">{i18n._('menu.imperial')}</Label> <Label for="imperial">{$_('menu.imperial')}</Label>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<RadioGroup.Item value="nautical" id="nautical" /> <RadioGroup.Item value="nautical" id="nautical" />
<Label for="nautical">{i18n._('menu.nautical')}</Label> <Label for="nautical">{$_('menu.nautical')}</Label>
</div> </div>
</RadioGroup.Root> </RadioGroup.Root>
</Label> </Label>
<Label class="flex flex-col items-start gap-2"> <Label class="flex flex-col items-start gap-2">
{i18n._('menu.velocity_units')} {$_('menu.velocity_units')}
<RadioGroup.Root bind:value={options.velocityUnits}> <RadioGroup.Root bind:value={options.velocityUnits}>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<RadioGroup.Item value="speed" id="speed" /> <RadioGroup.Item value="speed" id="speed" />
<Label for="speed">{i18n._('quantities.speed')}</Label> <Label for="speed">{$_('quantities.speed')}</Label>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<RadioGroup.Item value="pace" id="pace" /> <RadioGroup.Item value="pace" id="pace" />
<Label for="pace">{i18n._('quantities.pace')}</Label> <Label for="pace">{$_('quantities.pace')}</Label>
</div> </div>
</RadioGroup.Root> </RadioGroup.Root>
</Label> </Label>
<Label class="flex flex-col items-start gap-2"> <Label class="flex flex-col items-start gap-2">
{i18n._('menu.temperature_units')} {$_('menu.temperature_units')}
<RadioGroup.Root bind:value={options.temperatureUnits}> <RadioGroup.Root bind:value={options.temperatureUnits}>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<RadioGroup.Item value="celsius" id="celsius" /> <RadioGroup.Item value="celsius" id="celsius" />
<Label for="celsius">{i18n._('menu.celsius')}</Label> <Label for="celsius">{$_('menu.celsius')}</Label>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<RadioGroup.Item value="fahrenheit" id="fahrenheit" /> <RadioGroup.Item value="fahrenheit" id="fahrenheit" />
<Label for="fahrenheit">{i18n._('menu.fahrenheit')}</Label> <Label for="fahrenheit">{$_('menu.fahrenheit')}</Label>
</div> </div>
</RadioGroup.Root> </RadioGroup.Root>
</Label> </Label>
</div> </div>
<Label class="flex flex-col items-start gap-2"> <Label class="flex flex-col items-start gap-2">
{i18n._('menu.mode')} {$_('menu.mode')}
<RadioGroup.Root bind:value={options.theme} class="flex flex-row"> <RadioGroup.Root bind:value={options.theme} class="flex flex-row">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<RadioGroup.Item value="system" id="system" /> <RadioGroup.Item value="system" id="system" />
<Label for="system">{i18n._('menu.system')}</Label> <Label for="system">{$_('menu.system')}</Label>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<RadioGroup.Item value="light" id="light" /> <RadioGroup.Item value="light" id="light" />
<Label for="light">{i18n._('menu.light')}</Label> <Label for="light">{$_('menu.light')}</Label>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<RadioGroup.Item value="dark" id="dark" /> <RadioGroup.Item value="dark" id="dark" />
<Label for="dark">{i18n._('menu.dark')}</Label> <Label for="dark">{$_('menu.dark')}</Label>
</div> </div>
</RadioGroup.Root> </RadioGroup.Root>
</Label> </Label>
@@ -298,43 +290,43 @@
<Checkbox id="manual-camera" bind:checked={manualCamera} /> <Checkbox id="manual-camera" bind:checked={manualCamera} />
<Label for="manual-camera" class="flex flex-row items-center gap-1"> <Label for="manual-camera" class="flex flex-row items-center gap-1">
<Video size="16" /> <Video size="16" />
{i18n._('embedding.manual_camera')} {$_('embedding.manual_camera')}
</Label> </Label>
</div> </div>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
{i18n._('embedding.manual_camera_description')} {$_('embedding.manual_camera_description')}
</p> </p>
<div class="flex flex-row flex-wrap items-center gap-6"> <div class="flex flex-row flex-wrap items-center gap-6">
<Label class="flex flex-col gap-1"> <Label class="flex flex-col gap-1">
<span>{i18n._('embedding.latitude')}</span> <span>{$_('embedding.latitude')}</span>
<span>{lat}</span> <span>{lat}</span>
</Label> </Label>
<Label class="flex flex-col gap-1"> <Label class="flex flex-col gap-1">
<span>{i18n._('embedding.longitude')}</span> <span>{$_('embedding.longitude')}</span>
<span>{lon}</span> <span>{lon}</span>
</Label> </Label>
<Label class="flex flex-col gap-1"> <Label class="flex flex-col gap-1">
<span>{i18n._('embedding.zoom')}</span> <span>{$_('embedding.zoom')}</span>
<span>{zoom}</span> <span>{zoom}</span>
</Label> </Label>
<Label class="flex flex-col gap-1"> <Label class="flex flex-col gap-1">
<span>{i18n._('embedding.bearing')}</span> <span>{$_('embedding.bearing')}</span>
<span>{bearing}</span> <span>{bearing}</span>
</Label> </Label>
<Label class="flex flex-col gap-1"> <Label class="flex flex-col gap-1">
<span>{i18n._('embedding.pitch')}</span> <span>{$_('embedding.pitch')}</span>
<span>{pitch}</span> <span>{pitch}</span>
</Label> </Label>
</div> </div>
</div> </div>
<Label> <Label>
{i18n._('embedding.preview')} {$_('embedding.preview')}
</Label> </Label>
<div class="relative h-[600px]"> <div class="relative h-[600px]">
<Embedding bind:options={iframeOptions} bind:hash useHash={false} /> <Embedding bind:options={iframeOptions} bind:hash useHash={false} />
</div> </div>
<Label> <Label>
{i18n._('embedding.code')} {$_('embedding.code')}
</Label> </Label>
<pre <pre
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all"> class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">

View File

@@ -2,27 +2,22 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Logo from '$lib/components/Logo.svelte'; import Logo from '$lib/components/Logo.svelte';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { i18n } from '$lib/i18n.svelte'; import { _, locale } from 'svelte-i18n';
let { export let files: string[];
files, export let ids: string[];
ids,
}: {
files: string[];
ids: string[];
} = $props();
</script> </script>
<Button <Button
variant="ghost" 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" 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="{getURLForLanguage($locale, '/app')}?{files.length > 0
? `files=${encodeURIComponent(JSON.stringify(files))}` ? `files=${encodeURIComponent(JSON.stringify(files))}`
: ''}{files.length > 0 && ids.length > 0 ? '&' : ''}{ids.length > 0 : ''}{files.length > 0 && ids.length > 0 ? '&' : ''}{ids.length > 0
? `ids=${encodeURIComponent(JSON.stringify(ids))}` ? `ids=${encodeURIComponent(JSON.stringify(ids))}`
: ''}" : ''}"
target="_blank" target="_blank"
> >
{i18n._('menu.open_in')} {$_('menu.open_in')}
<Logo class="h-[18px] xs:h-5 translate-y-[1px]" /> <Logo class="h-[18px] xs:h-5 translate-y-[1px]" />
</Button> </Button>

View File

@@ -1,65 +0,0 @@
import { applyToOrderedSelectedItemsFromFile } from '$lib/logic/selection.svelte';
import { fileStateCollection } from '$lib/logic/file-state.svelte';
import { settings } from '$lib/logic/settings.svelte';
import { buildGPX, type GPXFile } from 'gpx';
import FileSaver from 'file-saver';
import JSZip from 'jszip';
export enum ExportState {
NONE,
SELECTION,
ALL,
}
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);
}
}
}
}
export async function exportSelectedFiles(exclude: string[]) {
const fileIds: string[] = [];
applyToOrderedSelectedItemsFromFile(async (fileId, level, items) => {
fileIds.push(fileId);
});
await exportFiles(fileIds, exclude);
}
export async function exportAllFiles(exclude: string[]) {
await exportFiles(settings.fileOrder.value, 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);
}
}
if (Object.keys(zip.files).length > 0) {
const blob = await zip.generateAsync({ type: 'blob' });
FileSaver.saveAs(blob, 'gpx-files.zip');
}
}

View File

@@ -4,24 +4,15 @@
import FileListNode from './FileListNode.svelte'; import FileListNode from './FileListNode.svelte';
import { fileObservers, settings } from '$lib/db'; import { fileObservers, settings } from '$lib/db';
import { setContext } from 'svelte'; import { setContext } from 'svelte';
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './file-list'; import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './FileList';
import { copied, pasteSelection, selectAll, selection } from './Selection'; import { copied, pasteSelection, selectAll, selection } from './Selection';
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte'; import { ClipboardPaste, FileStack, Plus } from 'lucide-svelte';
import Shortcut from '$lib/components/Shortcut.svelte'; import Shortcut from '$lib/components/Shortcut.svelte';
import { i18n } from '$lib/i18n.svelte'; import { _ } from 'svelte-i18n';
import { createFile } from '$lib/stores'; import { createFile } from '$lib/stores';
let { export let orientation: 'vertical' | 'horizontal';
orientation, export let recursive = false;
recursive = false,
class: className = '',
style = '',
}: {
orientation: 'vertical' | 'horizontal';
recursive?: boolean;
class?: string;
style?: string;
} = $props();
setContext('orientation', orientation); setContext('orientation', orientation);
setContext('recursive', recursive); setContext('recursive', recursive);
@@ -61,23 +52,23 @@
<div <div
class="flex {orientation === 'vertical' class="flex {orientation === 'vertical'
? 'flex-col py-1 pl-1 min-h-screen' ? 'flex-col py-1 pl-1 min-h-screen'
: 'flex-row'} {className ?? ''}" : 'flex-row'} {$$props.class ?? ''}"
{style} {...$$restProps}
> >
<FileListNode bind:node={$fileObservers} item={new ListRootItem()} /> <FileListNode bind:node={$fileObservers} item={new ListRootItem()} />
{#if orientation === 'vertical'} {#if orientation === 'vertical'}
<ContextMenu.Root> <ContextMenu.Root>
<ContextMenu.Trigger class="grow" /> <ContextMenu.Trigger class="grow" />
<ContextMenu.Content> <ContextMenu.Content>
<ContextMenu.Item onclick={createFile}> <ContextMenu.Item on:click={createFile}>
<Plus size="16" class="mr-1" /> <Plus size="16" class="mr-1" />
{i18n._('menu.new_file')} {$_('menu.new_file')}
<Shortcut key="+" ctrl={true} /> <Shortcut key="+" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
<ContextMenu.Item onclick={selectAll} disabled={$fileObservers.size === 0}> <ContextMenu.Item on:click={selectAll} disabled={$fileObservers.size === 0}>
<FileStack size="16" class="mr-1" /> <FileStack size="16" class="mr-1" />
{i18n._('menu.select_all')} {$_('menu.select_all')}
<Shortcut key="A" ctrl={true} /> <Shortcut key="A" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
@@ -85,10 +76,10 @@
disabled={$copied === undefined || disabled={$copied === undefined ||
$copied.length === 0 || $copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)} !allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
onclick={pasteSelection} on:click={pasteSelection}
> >
<ClipboardPaste size="16" class="mr-1" /> <ClipboardPaste size="16" class="mr-1" />
{i18n._('menu.paste')} {$_('menu.paste')}
<Shortcut key="V" ctrl={true} /> <Shortcut key="V" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
</ContextMenu.Content> </ContextMenu.Content>

View File

@@ -1,8 +1,8 @@
// import { dbUtils, getFile } from '$lib/db'; import { dbUtils, getFile } from '$lib/db';
// import { freeze } from 'immer'; import { freeze } from 'immer';
// import { GPXFile, Track, TrackSegment, Waypoint } from 'gpx'; import { GPXFile, Track, TrackSegment, Waypoint } from 'gpx';
// import { selection } from './Selection'; import { selection } from './Selection';
// import { newGPXFile } from '$lib/stores'; import { newGPXFile } from '$lib/stores';
export enum ListLevel { export enum ListLevel {
ROOT, ROOT,
@@ -32,7 +32,6 @@ export const allowedPastes: Record<ListLevel, ListLevel[]> = {
}; };
export abstract class ListItem { export abstract class ListItem {
[x: string]: any;
level: ListLevel; level: ListLevel;
constructor(level: ListLevel) { constructor(level: ListLevel) {
@@ -322,3 +321,163 @@ export function sortItems(items: ListItem[], reverse: boolean = false) {
items.reverse(); items.reverse();
} }
} }
export function moveItems(
fromParent: ListItem,
toParent: ListItem,
fromItems: ListItem[],
toItems: ListItem[],
remove: boolean = true
) {
if (fromItems.length === 0) {
return;
}
sortItems(fromItems, false);
sortItems(toItems, false);
let context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[] = [];
fromItems.forEach((item) => {
let file = getFile(item.getFileId());
if (file) {
if (item instanceof ListFileItem) {
context.push(file.clone());
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
context.push(file.trk[item.getTrackIndex()].clone());
} else if (
item instanceof ListTrackSegmentItem &&
item.getTrackIndex() < file.trk.length &&
item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length
) {
context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone());
} else if (item instanceof ListWaypointsItem) {
context.push(file.wpt.map((wpt) => wpt.clone()));
} else if (
item instanceof ListWaypointItem &&
item.getWaypointIndex() < file.wpt.length
) {
context.push(file.wpt[item.getWaypointIndex()].clone());
}
}
});
if (remove && !(fromParent instanceof ListRootItem)) {
sortItems(fromItems, true);
}
let files = [fromParent.getFileId(), toParent.getFileId()];
let callbacks = [
(file, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
fromItems.forEach((item) => {
if (item instanceof ListTrackItem) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
} else if (item instanceof ListTrackSegmentItem) {
file.replaceTrackSegments(
item.getTrackIndex(),
item.getSegmentIndex(),
item.getSegmentIndex(),
[]
);
} else if (item instanceof ListWaypointsItem) {
file.replaceWaypoints(0, file.wpt.length - 1, []);
} else if (item instanceof ListWaypointItem) {
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex(), []);
}
});
},
(file, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
toItems.forEach((item, i) => {
if (item instanceof ListTrackItem) {
if (context[i] instanceof Track) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
context[i],
]);
} else if (context[i] instanceof TrackSegment) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
new Track({
trkseg: [context[i]],
}),
]);
}
} else if (
item instanceof ListTrackSegmentItem &&
context[i] instanceof TrackSegment
) {
file.replaceTrackSegments(
item.getTrackIndex(),
item.getSegmentIndex(),
item.getSegmentIndex() - 1,
[context[i]]
);
} else if (item instanceof ListWaypointsItem) {
if (
Array.isArray(context[i]) &&
context[i].length > 0 &&
context[i][0] instanceof Waypoint
) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, context[i]);
} else if (context[i] instanceof Waypoint) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, [context[i]]);
}
} else if (item instanceof ListWaypointItem && context[i] instanceof Waypoint) {
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [
context[i],
]);
}
});
},
];
if (fromParent instanceof ListRootItem) {
files = [];
callbacks = [];
} else if (!remove) {
files.splice(0, 1);
callbacks.splice(0, 1);
}
dbUtils.applyEachToFilesAndGlobal(
files,
callbacks,
(files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
toItems.forEach((item, i) => {
if (item instanceof ListFileItem) {
if (context[i] instanceof GPXFile) {
let newFile = context[i];
if (remove) {
files.delete(newFile._data.id);
}
newFile._data.id = item.getFileId();
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof Track) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
if (context[i].name) {
newFile.metadata.name = context[i].name;
}
newFile.replaceTracks(0, 0, [context[i]]);
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof TrackSegment) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
newFile.replaceTracks(0, 0, [
new Track({
trkseg: [context[i]],
}),
]);
files.set(item.getFileId(), freeze(newFile));
}
}
});
},
context
);
selection.update(($selection) => {
$selection.clear();
toItems.forEach((item) => {
$selection.set(item, true);
});
return $selection;
});
}

View File

@@ -20,8 +20,8 @@
ListWaypointsItem, ListWaypointsItem,
type ListItem, type ListItem,
type ListTrackItem, type ListTrackItem,
} from './file-list'; } from './FileList';
import { i18n } from '$lib/i18n.svelte'; import { _ } from 'svelte-i18n';
import { selection } from './Selection'; import { selection } from './Selection';
export let node: export let node:
@@ -39,14 +39,14 @@
node instanceof GPXFile && item instanceof ListFileItem node instanceof GPXFile && item instanceof ListFileItem
? node.metadata.name ? node.metadata.name
: node instanceof Track : node instanceof Track
? (node.name ?? `${i18n._('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`) ? (node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
: node instanceof TrackSegment : node instanceof TrackSegment
? `${i18n._('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}` ? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
: node instanceof Waypoint : node instanceof Waypoint
? (node.name ?? ? (node.name ??
`${i18n._('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`) `${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
: node instanceof GPXFile && item instanceof ListWaypointsItem : node instanceof GPXFile && item instanceof ListWaypointsItem
? i18n._('gpx.waypoints') ? $_('gpx.waypoints')
: ''; : '';
const { treeFileView } = settings; const { treeFileView } = settings;
@@ -72,16 +72,12 @@
<FileListNodeLabel {node} {item} {label} /> <FileListNodeLabel {node} {item} {label} />
{:else if recursive} {:else if recursive}
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}> <CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
{#snippet trigger()} <FileListNodeLabel {node} {item} {label} slot="trigger" />
<FileListNodeLabel {node} {item} {label} /> <div slot="content" class="ml-2">
{/snippet}
{#snippet content()}
<div class="ml-2">
{#key node} {#key node}
<FileListNodeContent {node} {item} /> <FileListNodeContent {node} {item} />
{/key} {/key}
</div> </div>
{/snippet}
</CollapsibleTreeNode> </CollapsibleTreeNode>
{:else} {:else}
<FileListNodeLabel {node} {item} {label} /> <FileListNodeLabel {node} {item} {label} />

View File

@@ -20,9 +20,10 @@
allowedMoves, allowedMoves,
moveItems, moveItems,
type ListItem, type ListItem,
} from './file-list'; } from './FileList';
import { selection } from './Selection'; import { selection } from './Selection';
import { isMac } from '$lib/utils'; import { isMac } from '$lib/utils';
import { _ } from 'svelte-i18n';
export let node: export let node:
| Map<string, Readable<GPXFileWithStatistics | undefined>> | Map<string, Readable<GPXFileWithStatistics | undefined>>
@@ -344,8 +345,6 @@
{/if} {/if}
<style lang="postcss"> <style lang="postcss">
@reference "../../../app.css";
.sortable > div { .sortable > div {
@apply rounded-md; @apply rounded-md;
@apply h-fit; @apply h-fit;

View File

@@ -19,7 +19,7 @@
Scissors, Scissors,
FileStack, FileStack,
FileX, FileX,
} from '@lucide/svelte'; } from 'lucide-svelte';
import { import {
ListFileItem, ListFileItem,
ListLevel, ListLevel,
@@ -27,7 +27,7 @@
ListWaypointItem, ListWaypointItem,
allowedPastes, allowedPastes,
type ListItem, type ListItem,
} from './file-list'; } from './FileList';
import { import {
copied, copied,
copySelection, copySelection,
@@ -40,35 +40,35 @@
} from './Selection'; } from './Selection';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { allHidden, gpxLayers } from '$lib/stores'; import {
import { map, centerMapOnSelection } from '$lib/components/map/map.svelte'; allHidden,
editMetadata,
editStyle,
embedding,
centerMapOnSelection,
gpxLayers,
map,
} from '$lib/stores';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx'; import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { i18n } from '$lib/i18n.svelte'; import { _ } from 'svelte-i18n';
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte'; import MetadataDialog from './MetadataDialog.svelte';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte'; import StyleDialog from './StyleDialog.svelte';
import StyleDialog from './style/StyleDialog.svelte'; import { waypointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { waypointPopup } from '$lib/components/map/gpx-layer/GPXLayerPopup';
import { getSymbolKey, symbols } from '$lib/assets/symbols'; import { getSymbolKey, symbols } from '$lib/assets/symbols';
let { export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
node, export let item: ListItem;
item, export let label: string | undefined;
label,
}: {
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
item: ListItem;
label: string | undefined;
} = $props();
let orientation = getContext<'vertical' | 'horizontal'>('orientation'); let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let embedding = getContext<boolean>('embedding');
let singleSelection = $derived($selection.size === 1); $: singleSelection = $selection.size === 1;
let nodeColors: string[] = [];
$: if (node && $map) {
nodeColors = [];
let nodeColors: string[] = $derived.by(() => {
let colors: string[] = [];
if (node && map.current) {
if (node instanceof GPXFile) { if (node instanceof GPXFile) {
let defaultColor = undefined; let defaultColor = undefined;
@@ -79,54 +79,41 @@
let style = node.getStyle(defaultColor); let style = node.getStyle(defaultColor);
style.color.forEach((c) => { style.color.forEach((c) => {
if (!colors.includes(c)) { if (!nodeColors.includes(c)) {
colors.push(c); nodeColors.push(c);
} }
}); });
} else if (node instanceof Track) { } else if (node instanceof Track) {
let style = node.getStyle(); let style = node.getStyle();
if (style) { if (style) {
if ( if (style['gpx_style:color'] && !nodeColors.includes(style['gpx_style:color'])) {
style['gpx_style:color'] &&
!nodeColors.includes(style['gpx_style:color'])
) {
nodeColors.push(style['gpx_style:color']); nodeColors.push(style['gpx_style:color']);
} }
} }
if (colors.length === 0) { if (nodeColors.length === 0) {
let layer = gpxLayers.get(item.getFileId()); let layer = gpxLayers.get(item.getFileId());
if (layer) { if (layer) {
colors.push(layer.layerColor); nodeColors.push(layer.layerColor);
} }
} }
} }
} }
return colors;
});
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined); $: symbolKey = node instanceof Waypoint ? getSymbolKey(node.sym) : undefined;
let openEditMetadata: boolean = $state(false); let openEditMetadata: boolean = false;
let openEditStyle: boolean = $state(false); let openEditStyle: boolean = false;
$effect(() => { $: openEditMetadata = $editMetadata && singleSelection && $selection.has(item);
openEditMetadata = editMetadata.current && singleSelection && $selection.has(item); $: openEditStyle =
}); $editStyle &&
$effect(() => {
openEditStyle =
editStyle.current &&
$selection.has(item) && $selection.has(item) &&
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0; $selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0;
}); $: hidden = item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden;
let hidden = $derived(
item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden
);
</script> </script>
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<ContextMenu.Root <ContextMenu.Root
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) { if (open) {
@@ -161,7 +148,7 @@
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%` `${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
) )
.join(',')})" .join(',')})"
></div> />
{/if} {/if}
<span <span
class="w-full text-left truncate py-1 flex flex-row items-center {hidden class="w-full text-left truncate py-1 flex flex-row items-center {hidden
@@ -169,8 +156,8 @@
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId()) : ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
? 'text-muted-foreground' ? 'text-muted-foreground'
: ''}" : ''}"
oncontextmenu={(e) => { on:contextmenu={(e) => {
if (embedding) { if ($embedding) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
return; return;
@@ -183,7 +170,7 @@
$selection = $selection; $selection = $selection;
} }
}} }}
onmouseenter={() => { on:mouseenter={() => {
if (item instanceof ListWaypointItem) { if (item instanceof ListWaypointItem) {
let layer = gpxLayers.get(item.getFileId()); let layer = gpxLayers.get(item.getFileId());
let file = getFile(item.getFileId()); let file = getFile(item.getFileId());
@@ -198,7 +185,7 @@
} }
} }
}} }}
onmouseleave={() => { on:mouseleave={() => {
if (item instanceof ListWaypointItem) { if (item instanceof ListWaypointItem) {
let layer = gpxLayers.get(item.getFileId()); let layer = gpxLayers.get(item.getFileId());
if (layer) { if (layer) {
@@ -211,8 +198,11 @@
<Waypoints size="16" class="mr-1 shrink-0" /> <Waypoints size="16" class="mr-1 shrink-0" />
{:else if item.level === ListLevel.WAYPOINT} {:else if item.level === ListLevel.WAYPOINT}
{#if symbolKey && symbols[symbolKey].icon} {#if symbolKey && symbols[symbolKey].icon}
{@const SymbolIcon = symbols[symbolKey].icon} <svelte:component
<SymbolIcon size="16" class="mr-1 shrink-0" /> this={symbols[symbolKey].icon}
size="16"
class="mr-1 shrink-0"
/>
{:else} {:else}
<MapPin size="16" class="mr-1 shrink-0" /> <MapPin size="16" class="mr-1 shrink-0" />
{/if} {/if}
@@ -240,21 +230,18 @@
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Content> <ContextMenu.Content>
{#if item instanceof ListFileItem || item instanceof ListTrackItem} {#if item instanceof ListFileItem || item instanceof ListTrackItem}
<ContextMenu.Item <ContextMenu.Item disabled={!singleSelection} on:click={() => ($editMetadata = true)}>
disabled={!singleSelection}
onclick={() => (editMetadata.current = true)}
>
<Info size="16" class="mr-1" /> <Info size="16" class="mr-1" />
{i18n._('menu.metadata.button')} {$_('menu.metadata.button')}
<Shortcut key="I" ctrl={true} /> <Shortcut key="I" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Item onclick={() => (editStyle.current = true)}> <ContextMenu.Item on:click={() => ($editStyle = true)}>
<PaintBucket size="16" class="mr-1" /> <PaintBucket size="16" class="mr-1" />
{i18n._('menu.style.button')} {$_('menu.style.button')}
</ContextMenu.Item> </ContextMenu.Item>
{/if} {/if}
<ContextMenu.Item <ContextMenu.Item
onclick={() => { on:click={() => {
if ($allHidden) { if ($allHidden) {
dbUtils.setHiddenToSelection(false); dbUtils.setHiddenToSelection(false);
} else { } else {
@@ -264,10 +251,10 @@
> >
{#if $allHidden} {#if $allHidden}
<Eye size="16" class="mr-1" /> <Eye size="16" class="mr-1" />
{i18n._('menu.unhide')} {$_('menu.unhide')}
{:else} {:else}
<EyeOff size="16" class="mr-1" /> <EyeOff size="16" class="mr-1" />
{i18n._('menu.hide')} {$_('menu.hide')}
{/if} {/if}
<Shortcut key="H" ctrl={true} /> <Shortcut key="H" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
@@ -276,71 +263,71 @@
{#if item instanceof ListFileItem} {#if item instanceof ListFileItem}
<ContextMenu.Item <ContextMenu.Item
disabled={!singleSelection} disabled={!singleSelection}
onclick={() => dbUtils.addNewTrack(item.getFileId())} on:click={() => dbUtils.addNewTrack(item.getFileId())}
> >
<Plus size="16" class="mr-1" /> <Plus size="16" class="mr-1" />
{i18n._('menu.new_track')} {$_('menu.new_track')}
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
{:else if item instanceof ListTrackItem} {:else if item instanceof ListTrackItem}
<ContextMenu.Item <ContextMenu.Item
disabled={!singleSelection} disabled={!singleSelection}
onclick={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())} on:click={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())}
> >
<Plus size="16" class="mr-1" /> <Plus size="16" class="mr-1" />
{i18n._('menu.new_segment')} {$_('menu.new_segment')}
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
{/if} {/if}
{/if} {/if}
{#if item.level !== ListLevel.WAYPOINTS} {#if item.level !== ListLevel.WAYPOINTS}
<ContextMenu.Item onclick={selectAll}> <ContextMenu.Item on:click={selectAll}>
<FileStack size="16" class="mr-1" /> <FileStack size="16" class="mr-1" />
{i18n._('menu.select_all')} {$_('menu.select_all')}
<Shortcut key="A" ctrl={true} /> <Shortcut key="A" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
{/if} {/if}
<ContextMenu.Item onclick={centerMapOnSelection}> <ContextMenu.Item on:click={centerMapOnSelection}>
<Maximize size="16" class="mr-1" /> <Maximize size="16" class="mr-1" />
{i18n._('menu.center')} {$_('menu.center')}
<Shortcut key="⏎" ctrl={true} /> <Shortcut key="⏎" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
<ContextMenu.Item onclick={dbUtils.duplicateSelection}> <ContextMenu.Item on:click={dbUtils.duplicateSelection}>
<Copy size="16" class="mr-1" /> <Copy size="16" class="mr-1" />
{i18n._('menu.duplicate')} {$_('menu.duplicate')}
<Shortcut key="D" ctrl={true} /></ContextMenu.Item <Shortcut key="D" ctrl={true} /></ContextMenu.Item
> >
{#if orientation === 'vertical'} {#if orientation === 'vertical'}
<ContextMenu.Item onclick={copySelection}> <ContextMenu.Item on:click={copySelection}>
<ClipboardCopy size="16" class="mr-1" /> <ClipboardCopy size="16" class="mr-1" />
{i18n._('menu.copy')} {$_('menu.copy')}
<Shortcut key="C" ctrl={true} /> <Shortcut key="C" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Item onclick={cutSelection}> <ContextMenu.Item on:click={cutSelection}>
<Scissors size="16" class="mr-1" /> <Scissors size="16" class="mr-1" />
{i18n._('menu.cut')} {$_('menu.cut')}
<Shortcut key="X" ctrl={true} /> <Shortcut key="X" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Item <ContextMenu.Item
disabled={$copied === undefined || disabled={$copied === undefined ||
$copied.length === 0 || $copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(item.level)} !allowedPastes[$copied[0].level].includes(item.level)}
onclick={pasteSelection} on:click={pasteSelection}
> >
<ClipboardPaste size="16" class="mr-1" /> <ClipboardPaste size="16" class="mr-1" />
{i18n._('menu.paste')} {$_('menu.paste')}
<Shortcut key="V" ctrl={true} /> <Shortcut key="V" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
{/if} {/if}
<ContextMenu.Separator /> <ContextMenu.Separator />
<ContextMenu.Item onclick={dbUtils.deleteSelection}> <ContextMenu.Item on:click={dbUtils.deleteSelection}>
{#if item instanceof ListFileItem} {#if item instanceof ListFileItem}
<FileX size="16" class="mr-1" /> <FileX size="16" class="mr-1" />
{i18n._('menu.close')} {$_('menu.close')}
{:else} {:else}
<Trash2 size="16" class="mr-1" /> <Trash2 size="16" class="mr-1" />
{i18n._('menu.delete')} {$_('menu.delete')}
{/if} {/if}
<Shortcut key="⌫" ctrl={true} /> <Shortcut key="⌫" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>

View File

@@ -5,13 +5,9 @@
import type { GPXFileWithStatistics } from '$lib/db'; import type { GPXFileWithStatistics } from '$lib/db';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import type { Readable } from 'svelte/store'; import type { Readable } from 'svelte/store';
import { ListFileItem } from './file-list'; import { ListFileItem } from './FileList';
let { export let file: Readable<GPXFileWithStatistics | undefined>;
file,
}: {
file: Readable<GPXFileWithStatistics | undefined>;
} = $props();
let recursive = getContext<boolean>('recursive'); let recursive = getContext<boolean>('recursive');
</script> </script>

View File

@@ -5,54 +5,44 @@
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
import * as Popover from '$lib/components/ui/popover'; import * as Popover from '$lib/components/ui/popover';
import { dbUtils } from '$lib/db'; import { dbUtils } from '$lib/db';
import { Save } from '@lucide/svelte'; import { Save } from 'lucide-svelte';
import { ListFileItem, ListTrackItem, type ListItem } from '../file-list'; import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx'; import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { i18n } from '$lib/i18n.svelte'; import { _ } from 'svelte-i18n';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte'; import { editMetadata } from '$lib/stores';
let { export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
node, export let item: ListItem;
item, export let open = false;
open = $bindable(),
}: {
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
item: ListItem;
open: boolean;
} = $props();
let name: string = $derived( let name: string =
node instanceof GPXFile node instanceof GPXFile
? (node.metadata.name ?? '') ? (node.metadata.name ?? '')
: node instanceof Track : node instanceof Track
? (node.name ?? '') ? (node.name ?? '')
: '' : '';
); let description: string =
let description: string = $derived(
node instanceof GPXFile node instanceof GPXFile
? (node.metadata.desc ?? '') ? (node.metadata.desc ?? '')
: node instanceof Track : node instanceof Track
? (node.desc ?? '') ? (node.desc ?? '')
: '' : '';
);
$effect(() => { $: if (!open) {
if (!open) { $editMetadata = false;
editMetadata.current = false;
} }
});
</script> </script>
<Popover.Root bind:open> <Popover.Root bind:open>
<Popover.Trigger /> <Popover.Trigger />
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3"> <Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
<Label for="name">{i18n._('menu.metadata.name')}</Label> <Label for="name">{$_('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="font-semibold h-8" /> <Input bind:value={name} id="name" class="font-semibold h-8" />
<Label for="description">{i18n._('menu.metadata.description')}</Label> <Label for="description">{$_('menu.metadata.description')}</Label>
<Textarea bind:value={description} id="description" /> <Textarea bind:value={description} id="description" />
<Button <Button
variant="outline" variant="outline"
onclick={() => { on:click={() => {
dbUtils.applyToFile(item.getFileId(), (file) => { dbUtils.applyToFile(item.getFileId(), (file) => {
if (item instanceof ListFileItem && node instanceof GPXFile) { if (item instanceof ListFileItem && node instanceof GPXFile) {
file.metadata.name = name; file.metadata.name = name;
@@ -69,7 +59,7 @@
}} }}
> >
<Save size="16" class="mr-1" /> <Save size="16" class="mr-1" />
{i18n._('menu.metadata.save')} {$_('menu.metadata.save')}
</Button> </Button>
</Popover.Content> </Popover.Content>
</Popover.Root> </Popover.Root>

View File

@@ -0,0 +1,375 @@
import { get, writable } from 'svelte/store';
import {
ListFileItem,
ListItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
ListLevel,
sortItems,
ListWaypointsItem,
moveItems,
} from './FileList';
import { fileObservers, getFile, getFileIds, settings } from '$lib/db';
export class SelectionTreeType {
item: ListItem;
selected: boolean;
children: {
[key: string | number]: SelectionTreeType;
};
size: number = 0;
constructor(item: ListItem) {
this.item = item;
this.selected = false;
this.children = {};
}
clear() {
this.selected = false;
for (let key in this.children) {
this.children[key].clear();
}
this.size = 0;
}
_setOrToggle(item: ListItem, value?: boolean) {
if (item.level === this.item.level) {
let newSelected = value === undefined ? !this.selected : value;
if (this.selected !== newSelected) {
this.selected = newSelected;
this.size += this.selected ? 1 : -1;
}
} else {
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (!this.children.hasOwnProperty(id)) {
this.children[id] = new SelectionTreeType(this.item.extend(id));
}
this.size -= this.children[id].size;
this.children[id]._setOrToggle(item, value);
this.size += this.children[id].size;
}
}
}
set(item: ListItem, value: boolean) {
this._setOrToggle(item, value);
}
toggle(item: ListItem) {
this._setOrToggle(item);
}
has(item: ListItem): boolean {
if (item.level === this.item.level) {
return this.selected;
} else {
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].has(item);
}
}
}
return false;
}
hasAnyParent(item: ListItem, self: boolean = true): boolean {
if (
this.selected &&
this.item.level <= item.level &&
(self || this.item.level < item.level)
) {
return this.selected;
}
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].hasAnyParent(item, self);
}
}
return false;
}
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
if (
this.selected &&
this.item.level >= item.level &&
(self || this.item.level > item.level)
) {
return this.selected;
}
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (ignoreIds === undefined || ignoreIds.indexOf(id) === -1) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].hasAnyChildren(item, self, ignoreIds);
}
}
} else {
for (let key in this.children) {
if (ignoreIds === undefined || ignoreIds.indexOf(key) === -1) {
if (this.children[key].hasAnyChildren(item, self, ignoreIds)) {
return true;
}
}
}
}
return false;
}
getSelected(selection: ListItem[] = []): ListItem[] {
if (this.selected) {
selection.push(this.item);
}
for (let key in this.children) {
this.children[key].getSelected(selection);
}
return selection;
}
forEach(callback: (item: ListItem) => void) {
if (this.selected) {
callback(this.item);
}
for (let key in this.children) {
this.children[key].forEach(callback);
}
}
getChild(id: string | number): SelectionTreeType | undefined {
return this.children[id];
}
deleteChild(id: string | number) {
if (this.children.hasOwnProperty(id)) {
this.size -= this.children[id].size;
delete this.children[id];
}
}
}
export const selection = writable<SelectionTreeType>(new SelectionTreeType(new ListRootItem()));
export function selectItem(item: ListItem) {
selection.update(($selection) => {
$selection.clear();
$selection.set(item, true);
return $selection;
});
}
export function selectFile(fileId: string) {
selectItem(new ListFileItem(fileId));
}
export function addSelectItem(item: ListItem) {
selection.update(($selection) => {
$selection.toggle(item);
return $selection;
});
}
export function addSelectFile(fileId: string) {
addSelectItem(new ListFileItem(fileId));
}
export function selectAll() {
selection.update(($selection) => {
let item: ListItem = new ListRootItem();
$selection.forEach((i) => {
item = i;
});
if (item instanceof ListRootItem || item instanceof ListFileItem) {
$selection.clear();
get(fileObservers).forEach((_file, fileId) => {
$selection.set(new ListFileItem(fileId), true);
});
} else if (item instanceof ListTrackItem) {
let file = getFile(item.getFileId());
if (file) {
file.trk.forEach((_track, trackId) => {
$selection.set(new ListTrackItem(item.getFileId(), trackId), true);
});
}
} else if (item instanceof ListTrackSegmentItem) {
let file = getFile(item.getFileId());
if (file) {
file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => {
$selection.set(
new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId),
true
);
});
}
} else if (item instanceof ListWaypointItem) {
let file = getFile(item.getFileId());
if (file) {
file.wpt.forEach((_waypoint, waypointId) => {
$selection.set(new ListWaypointItem(item.getFileId(), waypointId), true);
});
}
}
return $selection;
});
}
export function getOrderedSelection(reverse: boolean = false): ListItem[] {
let selected: ListItem[] = [];
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
selected.push(...items);
}, reverse);
return selected;
}
export function applyToOrderedItemsFromFile(
selectedItems: ListItem[],
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
reverse: boolean = true
) {
get(settings.fileOrder).forEach((fileId) => {
let level: ListLevel | undefined = undefined;
let items: ListItem[] = [];
selectedItems.forEach((item) => {
if (item.getFileId() === fileId) {
level = item.level;
if (
item instanceof ListFileItem ||
item instanceof ListTrackItem ||
item instanceof ListTrackSegmentItem ||
item instanceof ListWaypointsItem ||
item instanceof ListWaypointItem
) {
items.push(item);
}
}
});
if (items.length > 0) {
sortItems(items, reverse);
callback(fileId, level, items);
}
});
}
export function applyToOrderedSelectedItemsFromFile(
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
reverse: boolean = true
) {
applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse);
}
export const copied = writable<ListItem[] | undefined>(undefined);
export const cut = writable(false);
export function copySelection(): boolean {
let selected = get(selection).getSelected();
if (selected.length > 0) {
copied.set(selected);
cut.set(false);
return true;
}
return false;
}
export function cutSelection() {
if (copySelection()) {
cut.set(true);
}
}
function resetCopied() {
copied.set(undefined);
cut.set(false);
}
export function pasteSelection() {
let fromItems = get(copied);
if (fromItems === undefined || fromItems.length === 0) {
return;
}
let selected = get(selection).getSelected();
if (selected.length === 0) {
selected = [new ListRootItem()];
}
let fromParent = fromItems[0].getParent();
let toParent = selected[selected.length - 1];
let startIndex: number | undefined = undefined;
if (fromItems[0].level === toParent.level) {
if (
toParent instanceof ListTrackItem ||
toParent instanceof ListTrackSegmentItem ||
toParent instanceof ListWaypointItem
) {
startIndex = toParent.getId() + 1;
}
toParent = toParent.getParent();
}
let toItems: ListItem[] = [];
if (toParent.level === ListLevel.ROOT) {
let fileIds = getFileIds(fromItems.length);
fileIds.forEach((fileId) => {
toItems.push(new ListFileItem(fileId));
});
} else {
let toFile = getFile(toParent.getFileId());
if (toFile) {
fromItems.forEach((item, index) => {
if (toParent instanceof ListFileItem) {
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
toItems.push(
new ListTrackItem(
toParent.getFileId(),
(startIndex ?? toFile.trk.length) + index
)
);
} else if (item instanceof ListWaypointsItem) {
toItems.push(new ListWaypointsItem(toParent.getFileId()));
} else if (item instanceof ListWaypointItem) {
toItems.push(
new ListWaypointItem(
toParent.getFileId(),
(startIndex ?? toFile.wpt.length) + index
)
);
}
} else if (toParent instanceof ListTrackItem) {
if (item instanceof ListTrackSegmentItem) {
let toTrackIndex = toParent.getTrackIndex();
toItems.push(
new ListTrackSegmentItem(
toParent.getFileId(),
toTrackIndex,
(startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index
)
);
}
} else if (toParent instanceof ListWaypointsItem) {
if (item instanceof ListWaypointItem) {
toItems.push(
new ListWaypointItem(
toParent.getFileId(),
(startIndex ?? toFile.wpt.length) + index
)
);
}
}
});
}
}
if (fromItems.length === toItems.length) {
moveItems(fromParent, toParent, fromItems, toItems, get(cut));
resetCopied();
}
}

View File

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

View File

@@ -1,3 +0,0 @@
export const editMetadata = $state({
current: false,
});

View File

@@ -1,168 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label/index.js';
import { Slider } from '$lib/components/ui/slider';
import * as Popover from '$lib/components/ui/popover';
import { dbUtils, getFile, settings } from '$lib/db';
import { Save } from '@lucide/svelte';
import {
ListFileItem,
ListTrackItem,
type ListItem,
} from '$lib/components/file-list/file-list';
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { selection } from '../Selection';
import { gpxLayers } from '$lib/stores';
import { i18n } from '$lib/i18n.svelte';
import type { LineStyleExtension } from 'gpx';
let {
item,
open = $bindable(),
}: {
item: ListItem;
open: boolean;
} = $props();
const { defaultOpacity, defaultWidth } = settings;
let color: string = $state('');
let opacity: number = $state(0);
let width: number = $state(0);
let colorChanged = $state(false);
let opacityChanged = $state(false);
let widthChanged = $state(false);
function setStyleInputs() {
opacity = $defaultOpacity;
width = $defaultWidth;
$selection.forEach((item) => {
if (item instanceof ListFileItem) {
let file = getFile(item.getFileId());
let layer = gpxLayers.get(item.getFileId());
if (file && layer) {
let style = file.getStyle();
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 = getFile(item.getFileId());
let layer = gpxLayers.get(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'];
}
}
}
}
});
colorChanged = false;
opacityChanged = false;
widthChanged = false;
}
$effect(() => {
if ($selection && open) {
setStyleInputs();
}
});
$effect(() => {
if (!open) {
editStyle.current = false;
}
});
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;
}
dbUtils.setStyleToSelection(style);
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
if (style['gpx_style:opacity']) {
$defaultOpacity = style['gpx_style:opacity'];
}
if (style['gpx_style:width']) {
$defaultWidth = style['gpx_style:width'];
}
}
open = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger />
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
<Label class="flex flex-row gap-2 items-center justify-between">
{i18n._('menu.style.color')}
<Input
bind:value={color}
type="color"
class="p-0 h-6 w-40"
onchange={() => (colorChanged = true)}
/>
</Label>
<Label class="flex flex-row gap-2 items-center justify-between">
{i18n._('menu.style.opacity')}
<div class="w-40 p-2">
<Slider
bind:value={opacity}
min={0.3}
max={1}
step={0.1}
onValueChange={() => (opacityChanged = true)}
type="single"
/>
</div>
</Label>
<Label class="flex flex-row gap-2 items-center justify-between">
{i18n._('menu.style.width')}
<div class="w-40 p-2">
<Slider
bind:value={width}
id="width"
min={1}
max={10}
step={1}
onValueChange={() => (widthChanged = true)}
type="single"
/>
</div>
</Label>
<Button
variant="outline"
disabled={!colorChanged && !opacityChanged && !widthChanged}
onclick={applyStyle}
>
<Save size="16" class="mr-1" />
{i18n._('menu.metadata.save')}
</Button>
</Popover.Content>
</Popover.Root>

View File

@@ -1,3 +0,0 @@
export const editStyle = $state({
current: false,
});

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { ClipboardCopy } from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import type { Coordinates } from 'gpx';
export let coordinates: Coordinates;
export let onCopy: () => void = () => {};
</script>
<Button
class="w-full px-2 py-1 h-8 justify-start {$$props.class}"
variant="outline"
on:click={() => {
navigator.clipboard.writeText(
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}`
);
onCopy();
}}
>
<ClipboardCopy size="16" class="mr-1" />
{$_('menu.copy_coordinates')}
</Button>

View File

@@ -1,9 +1,8 @@
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { gpxStatistics } from '$lib/stores'; import { gpxStatistics } from '$lib/stores';
import type { GeoJSONSource } from 'mapbox-gl';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
// const { distanceMarkers, distanceUnits } = settings; const { distanceMarkers, distanceUnits } = settings;
const stops = [ const stops = [
[100, 0], [100, 0],
@@ -31,8 +30,7 @@ export class DistanceMarkers {
update() { update() {
try { try {
if (get(distanceMarkers)) { if (get(distanceMarkers)) {
let distanceSource: GeoJSONSource | undefined = let distanceSource = this.map.getSource('distance-markers');
this.map.getSource('distance-markers');
if (distanceSource) { if (distanceSource) {
distanceSource.setData(this.getDistanceMarkersGeoJSON()); distanceSource.setData(this.getDistanceMarkersGeoJSON());
} else { } else {

View File

@@ -1,8 +1,9 @@
import { currentTool, map, splitAs, Tool } from '$lib/stores'; import { currentTool, map, Tool } from '$lib/stores';
import { settings, type GPXFileWithStatistics, dbUtils } from '$lib/db'; import { settings, type GPXFileWithStatistics, dbUtils } from '$lib/db';
import { get, type Readable } from 'svelte/store'; import { get, type Readable } from 'svelte/store';
import mapboxgl from 'mapbox-gl'; import mapboxgl from 'mapbox-gl';
import { waypointPopup, deleteWaypoint, trackpointPopup } from './GPXLayerPopup'; import { waypointPopup, deleteWaypoint, trackpointPopup } from './GPXLayerPopup';
import { addSelectItem, selectItem, selection } from '$lib/components/file-list/Selection';
import { import {
ListTrackSegmentItem, ListTrackSegmentItem,
ListWaypointItem, ListWaypointItem,
@@ -10,7 +11,7 @@ import {
ListTrackItem, ListTrackItem,
ListFileItem, ListFileItem,
ListRootItem, ListRootItem,
} from '$lib/components/file-list/file-list'; } from '$lib/components/file-list/FileList';
import { import {
getClosestLinePoint, getClosestLinePoint,
getElevation, getElevation,
@@ -19,7 +20,7 @@ import {
setPointerCursor, setPointerCursor,
setScissorsCursor, setScissorsCursor,
} from '$lib/utils'; } from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/utils.svelte'; import { selectedWaypoint } from '$lib/components/toolbar/tools/Waypoint.svelte';
import { MapPin, Square } from 'lucide-static'; import { MapPin, Square } from 'lucide-static';
import { getSymbolKey, symbols } from '$lib/assets/symbols'; import { getSymbolKey, symbols } from '$lib/assets/symbols';
@@ -81,7 +82,7 @@ function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
</svg>`; </svg>`;
} }
// const { directionMarkers, treeFileView, defaultOpacity, defaultWidth } = settings; const { directionMarkers, treeFileView, defaultOpacity, defaultWidth } = settings;
export class GPXLayer { export class GPXLayer {
map: mapboxgl.Map; map: mapboxgl.Map;
@@ -468,7 +469,7 @@ export class GPXLayer {
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
) )
) { ) {
dbUtils.split(splitAs.current, this.fileId, trackIndex, segmentIndex, { dbUtils.split(this.fileId, trackIndex, segmentIndex, {
lat: e.lngLat.lat, lat: e.lngLat.lat,
lon: e.lngLat.lng, lon: e.lngLat.lng,
}); });

View File

@@ -1,5 +1,5 @@
import { dbUtils } from '$lib/db'; import { dbUtils } from '$lib/db';
import { MapPopup } from '$lib/components/map/map.svelte'; import { MapPopup } from '$lib/components/MapPopup';
export let waypointPopup: MapPopup | null = null; export let waypointPopup: MapPopup | null = null;
export let trackpointPopup: MapPopup | null = null; export let trackpointPopup: MapPopup | null = null;

View File

@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import type { TrackPoint } from 'gpx'; import type { TrackPoint } from 'gpx';
import type { PopupItem } from '$lib/components/map/map.svelte'; import type { PopupItem } from '$lib/components/MapPopup';
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte'; import CopyCoordinates from '$lib/components/gpx-layer/CopyCoordinates.svelte';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import WithUnits from '$lib/components/WithUnits.svelte'; import WithUnits from '$lib/components/WithUnits.svelte';
import { Compass, Mountain, Timer } from '@lucide/svelte'; import { Compass, Mountain, Timer } from 'lucide-svelte';
import { i18n } from '$lib/i18n.svelte'; import { df } from '$lib/utils';
import { _ } from 'svelte-i18n';
let { trackpoint }: { trackpoint: PopupItem<TrackPoint> } = $props(); export let trackpoint: PopupItem<TrackPoint>;
</script> </script>
<Card.Root class="border-none shadow-md text-base p-2"> <Card.Root class="border-none shadow-md text-base p-2">
@@ -30,7 +31,7 @@
{#if trackpoint.item.time} {#if trackpoint.item.time}
<div class="flex flex-row items-center gap-1"> <div class="flex flex-row items-center gap-1">
<Timer size="14" /> <Timer size="14" />
{i18n.df.format(trackpoint.item.time)} {df.format(trackpoint.item.time)}
</div> </div>
{/if} {/if}
<CopyCoordinates <CopyCoordinates

View File

@@ -2,16 +2,16 @@
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Shortcut from '$lib/components/Shortcut.svelte'; import Shortcut from '$lib/components/Shortcut.svelte';
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte'; import CopyCoordinates from '$lib/components/gpx-layer/CopyCoordinates.svelte';
import { deleteWaypoint } from './GPXLayerPopup'; import { deleteWaypoint } from './GPXLayerPopup';
import WithUnits from '$lib/components/WithUnits.svelte'; import WithUnits from '$lib/components/WithUnits.svelte';
import { Dot, ExternalLink, Trash2 } from '@lucide/svelte'; import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
import { tool, Tool } from '$lib/components/toolbar/utils.svelte'; import { Tool, currentTool } from '$lib/stores';
import { getSymbolKey, symbols } from '$lib/assets/symbols'; import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { i18n } from '$lib/i18n.svelte'; import { _ } from 'svelte-i18n';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import type { Waypoint } from 'gpx'; import type { Waypoint } from 'gpx';
import type { PopupItem } from '$lib/components/map/map.svelte'; import type { PopupItem } from '$lib/components/MapPopup';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
export let waypoint: PopupItem<Waypoint>; export let waypoint: PopupItem<Waypoint>;
@@ -41,7 +41,7 @@
<ExternalLink size="12" class="inline-block mb-1.5" /> <ExternalLink size="12" class="inline-block mb-1.5" />
</a> </a>
{:else} {:else}
{waypoint.item.name ?? i18n._('gpx.waypoint')} {waypoint.item.name ?? $_('gpx.waypoint')}
{/if} {/if}
</Card.Title> </Card.Title>
</Card.Header> </Card.Header>
@@ -56,9 +56,9 @@
class="inline-block mb-0.5" class="inline-block mb-0.5"
/> />
{:else} {:else}
<span class="w-4 inline-block"></span> <span class="w-4 inline-block" />
{/if} {/if}
{i18n._(`gpx.symbol.${symbolKey}`)} {$_(`gpx.symbol.${symbolKey}`)}
</span> </span>
<Dot size="16" /> <Dot size="16" />
{/if} {/if}
@@ -70,7 +70,7 @@
<WithUnits value={waypoint.item.ele} type="elevation" /> <WithUnits value={waypoint.item.ele} type="elevation" />
{/if} {/if}
</div> </div>
<ScrollArea class="flex flex-col max-h-[30dvh]"> <ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
{#if waypoint.item.desc} {#if waypoint.item.desc}
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span> <span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
{/if} {/if}
@@ -80,14 +80,14 @@
</ScrollArea> </ScrollArea>
<div class="mt-2 flex flex-col gap-1"> <div class="mt-2 flex flex-col gap-1">
<CopyCoordinates coordinates={waypoint.item.attributes} /> <CopyCoordinates coordinates={waypoint.item.attributes} />
{#if tool.current === Tool.WAYPOINT} {#if $currentTool === Tool.WAYPOINT}
<Button <Button
class="w-full px-2 py-1 h-8 justify-start" class="w-full px-2 py-1 h-8 justify-start"
variant="outline" variant="outline"
onclick={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)} on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
> >
<Trash2 size="16" class="mr-1" /> <Trash2 size="16" class="mr-1" />
{i18n._('menu.delete')} {$_('menu.delete')}
<Shortcut shift={true} click={true} /> <Shortcut shift={true} click={true} />
</Button> </Button>
{/if} {/if}
@@ -96,8 +96,6 @@
</Card.Root> </Card.Root>
<style lang="postcss"> <style lang="postcss">
@reference "../../../../app.css";
div :global(a) { div :global(a) {
@apply text-link; @apply text-link;
@apply hover:underline; @apply hover:underline;

View File

@@ -16,14 +16,14 @@
Move, Move,
Map, Map,
Layers2, Layers2,
} from '@lucide/svelte'; } from 'lucide-svelte';
import { i18n } from '$lib/i18n.svelte'; import { _ } from 'svelte-i18n';
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers'; import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
import { map } from '$lib/stores'; import { map } from '$lib/stores';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import Sortable from 'sortablejs/Sortable'; import Sortable from 'sortablejs/Sortable';
import { customBasemapUpdate } from './utils.svelte'; import { customBasemapUpdate } from './utils';
const { const {
customLayers, customLayers,
@@ -298,7 +298,7 @@
{#if $customBasemapOrder.length > 0} {#if $customBasemapOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2"> <div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Map size="16" /> <Map size="16" />
{i18n._('layers.label.basemaps')} {$_('layers.label.basemaps')}
<div class="grow"> <div class="grow">
<Separator /> <Separator />
</div> </div>
@@ -324,7 +324,7 @@
{#if $customOverlayOrder.length > 0} {#if $customOverlayOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2"> <div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Layers2 size="16" /> <Layers2 size="16" />
{i18n._('layers.label.overlays')} {$_('layers.label.overlays')}
<div class="grow"> <div class="grow">
<Separator /> <Separator />
</div> </div>
@@ -352,24 +352,24 @@
<Card.Header class="p-3"> <Card.Header class="p-3">
<Card.Title class="text-base"> <Card.Title class="text-base">
{#if selectedLayerId} {#if selectedLayerId}
{i18n._('layers.custom_layers.edit')} {$_('layers.custom_layers.edit')}
{:else} {:else}
{i18n._('layers.custom_layers.new')} {$_('layers.custom_layers.new')}
{/if} {/if}
</Card.Title> </Card.Title>
</Card.Header> </Card.Header>
<Card.Content class="p-3 pt-0"> <Card.Content class="p-3 pt-0">
<fieldset class="flex flex-col gap-2"> <fieldset class="flex flex-col gap-2">
<Label for="name">{i18n._('menu.metadata.name')}</Label> <Label for="name">{$_('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="h-8" /> <Input bind:value={name} id="name" class="h-8" />
<Label for="url">{i18n._('layers.custom_layers.urls')}</Label> <Label for="url">{$_('layers.custom_layers.urls')}</Label>
{#each tileUrls as url, i} {#each tileUrls as url, i}
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<Input <Input
bind:value={tileUrls[i]} bind:value={tileUrls[i]}
id="url" id="url"
class="h-8" class="h-8"
placeholder={i18n._('layers.custom_layers.url_placeholder')} placeholder={$_('layers.custom_layers.url_placeholder')}
/> />
{#if tileUrls.length > 1} {#if tileUrls.length > 1}
<Button <Button
@@ -393,7 +393,7 @@
</div> </div>
{/each} {/each}
{#if resourceType === 'raster'} {#if resourceType === 'raster'}
<Label for="maxZoom">{i18n._('layers.custom_layers.max_zoom')}</Label> <Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
<Input <Input
type="number" type="number"
bind:value={maxZoom} bind:value={maxZoom}
@@ -403,22 +403,22 @@
class="h-8" class="h-8"
/> />
{/if} {/if}
<Label>{i18n._('layers.custom_layers.layer_type')}</Label> <Label>{$_('layers.custom_layers.layer_type')}</Label>
<RadioGroup.Root bind:value={layerType} class="flex flex-row"> <RadioGroup.Root bind:value={layerType} class="flex flex-row">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<RadioGroup.Item value="basemap" id="basemap" /> <RadioGroup.Item value="basemap" id="basemap" />
<Label for="basemap">{i18n._('layers.custom_layers.basemap')}</Label> <Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<RadioGroup.Item value="overlay" id="overlay" /> <RadioGroup.Item value="overlay" id="overlay" />
<Label for="overlay">{i18n._('layers.custom_layers.overlay')}</Label> <Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
</div> </div>
</RadioGroup.Root> </RadioGroup.Root>
{#if selectedLayerId} {#if selectedLayerId}
<div class="mt-2 flex flex-row gap-2"> <div class="mt-2 flex flex-row gap-2">
<Button variant="outline" on:click={createLayer} class="grow"> <Button variant="outline" on:click={createLayer} class="grow">
<Save size="16" class="mr-1" /> <Save size="16" class="mr-1" />
{i18n._('layers.custom_layers.update')} {$_('layers.custom_layers.update')}
</Button> </Button>
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}> <Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
<CircleX size="16" /> <CircleX size="16" />
@@ -427,7 +427,7 @@
{:else} {:else}
<Button variant="outline" class="mt-2" on:click={createLayer}> <Button variant="outline" class="mt-2" on:click={createLayer}>
<CirclePlus size="16" class="mr-1" /> <CirclePlus size="16" class="mr-1" />
{i18n._('layers.custom_layers.create')} {$_('layers.custom_layers.create')}
</Button> </Button>
{/if} {/if}
</fieldset> </fieldset>

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import LayerTree from './LayerTree.svelte'; import LayerTree from './LayerTree.svelte';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import * as Sheet from '$lib/components/ui/sheet'; import * as Sheet from '$lib/components/ui/sheet';
@@ -7,6 +8,7 @@
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import { Slider } from '$lib/components/ui/slider'; import { Slider } from '$lib/components/ui/slider';
import { import {
basemapTree, basemapTree,
defaultBasemap, defaultBasemap,
@@ -14,10 +16,12 @@
overlayTree, overlayTree,
overpassTree, overpassTree,
} from '$lib/assets/layers'; } from '$lib/assets/layers';
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils.svelte'; import { getLayers, isSelected, toggle } from '$lib/components/layer-control/utils';
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { i18n } from '$lib/i18n.svelte';
import { map } from '$lib/components/map/map.svelte'; import { _ } from 'svelte-i18n';
import { writable } from 'svelte/store';
import { map } from '$lib/stores';
import CustomLayers from './CustomLayers.svelte'; import CustomLayers from './CustomLayers.svelte';
const { const {
@@ -30,21 +34,22 @@
opacities, opacities,
} = settings; } = settings;
let { open = $bindable() }: { open: boolean } = $props(); export let open: boolean;
let accordionValue: string | string[] | undefined = undefined;
let accordionValue: string | undefined = $state(undefined); let selectedOverlay = writable(undefined);
let selectedOverlay = $state(undefined); let overlayOpacity = writable([1]);
let overlayOpacity = $state(1);
function setOpacityFromSelection() { function setOpacityFromSelection() {
if (selectedOverlay) { if ($selectedOverlay) {
if ($opacities.hasOwnProperty(selectedOverlay)) { let overlayId = $selectedOverlay.value;
overlayOpacity = $opacities[selectedOverlay]; if ($opacities.hasOwnProperty(overlayId)) {
$overlayOpacity = [$opacities[overlayId]];
} else { } else {
overlayOpacity = 1; $overlayOpacity = [1];
} }
} else { } else {
overlayOpacity = 1; $overlayOpacity = [1];
} }
} }
@@ -71,20 +76,24 @@
}); });
} }
} }
$: if ($selectedOverlay) {
setOpacityFromSelection();
}
</script> </script>
<Sheet.Root bind:open> <Sheet.Root bind:open>
<Sheet.Trigger class="hidden" /> <Sheet.Trigger class="hidden" />
<Sheet.Content> <Sheet.Content>
<Sheet.Header class="h-full"> <Sheet.Header class="h-full">
<Sheet.Title>{i18n._('layers.settings')}</Sheet.Title> <Sheet.Title>{$_('layers.settings')}</Sheet.Title>
<ScrollArea class="w-[105%] min-h-full pr-4"> <ScrollArea class="w-[105%] min-h-full pr-4">
<Sheet.Description> <Sheet.Description>
{i18n._('layers.settings_help')} {$_('layers.settings_help')}
</Sheet.Description> </Sheet.Description>
<Accordion.Root class="flex flex-col" bind:value={accordionValue} type="single"> <Accordion.Root class="flex flex-col" bind:value={accordionValue}>
<Accordion.Item value="layer-selection" class="flex flex-col"> <Accordion.Item value="layer-selection" class="flex flex-col">
<Accordion.Trigger>{i18n._('layers.selection')}</Accordion.Trigger> <Accordion.Trigger>{$_('layers.selection')}</Accordion.Trigger>
<Accordion.Content class="grow flex flex-col border rounded"> <Accordion.Content class="grow flex flex-col border rounded">
<div class="py-2 pl-1 pr-2"> <div class="py-2 pl-1 pr-2">
<LayerTree <LayerTree
@@ -115,31 +124,21 @@
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
<Accordion.Item value="overlay-opacity"> <Accordion.Item value="overlay-opacity">
<Accordion.Trigger>{i18n._('layers.opacity')}</Accordion.Trigger> <Accordion.Trigger>{$_('layers.opacity')}</Accordion.Trigger>
<Accordion.Content class="flex flex-col gap-3 overflow-visible"> <Accordion.Content class="flex flex-col gap-3 overflow-visible">
<div class="flex flex-row gap-6 items-center"> <div class="flex flex-row gap-6 items-center">
<Label> <Label>
{i18n._('layers.custom_layers.overlay')} {$_('layers.custom_layers.overlay')}
</Label> </Label>
<Select.Root <Select.Root bind:selected={$selectedOverlay}>
bind:value={selectedOverlay}
type="single"
onValueChange={setOpacityFromSelection}
>
<Select.Trigger class="h-8 mr-1"> <Select.Trigger class="h-8 mr-1">
{#if selectedOverlay} <Select.Value />
{#if isSelected($selectedOverlayTree, selectedOverlay)}
{i18n._(`layers.label.${selectedOverlay}`)}
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
{$customLayers[selectedOverlay].name}
{/if}
{/if}
</Select.Trigger> </Select.Trigger>
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto"> <Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
{#each Object.keys(overlays) as id} {#each Object.keys(overlays) as id}
{#if isSelected($selectedOverlayTree, id)} {#if isSelected($selectedOverlayTree, id)}
<Select.Item value={id} <Select.Item value={id}
>{i18n._(`layers.label.${id}`)}</Select.Item >{$_(`layers.label.${id}`)}</Select.Item
> >
{/if} {/if}
{/each} {/each}
@@ -152,28 +151,30 @@
</Select.Root> </Select.Root>
</div> </div>
<Label class="flex flex-row gap-6 items-center"> <Label class="flex flex-row gap-6 items-center">
{i18n._('menu.style.opacity')} {$_('menu.style.opacity')}
<div class="p-2 pr-3 grow"> <div class="p-2 pr-3 grow">
<Slider <Slider
bind:value={overlayOpacity} bind:value={$overlayOpacity}
type="single"
min={0.1} min={0.1}
max={1} max={1}
step={0.1} step={0.1}
disabled={selectedOverlay === undefined} disabled={$selectedOverlay === undefined}
onValueChange={(value) => { onValueChange={(value) => {
if (selectedOverlay) { if ($selectedOverlay) {
if ( if (
map.current && $map &&
isSelected($currentOverlays, selectedOverlay) isSelected(
$currentOverlays,
$selectedOverlay.value
)
) { ) {
try { try {
map.current.removeImport(selectedOverlay); $map.removeImport($selectedOverlay.value);
} catch (e) { } catch (e) {
// No reliable way to check if the map is ready to remove sources and layers // No reliable way to check if the map is ready to remove sources and layers
} }
} }
$opacities[selectedOverlay] = value; $opacities[$selectedOverlay.value] = value[0];
} }
}} }}
/> />
@@ -182,8 +183,7 @@
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
<Accordion.Item value="custom-layers"> <Accordion.Item value="custom-layers">
<Accordion.Trigger>{i18n._('layers.custom_layers.title')}</Accordion.Trigger <Accordion.Trigger>{$_('layers.custom_layers.title')}</Accordion.Trigger>
>
<Accordion.Content> <Accordion.Content>
<ScrollArea> <ScrollArea>
<CustomLayers /> <CustomLayers />

View File

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

View File

@@ -1,32 +1,25 @@
<script lang="ts"> <script lang="ts">
import Self from '$lib/components/map/layer-control/LayerTreeNode.svelte';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import CollapsibleTreeNode from '$lib/components/collapsible-tree/CollapsibleTreeNode.svelte'; import CollapsibleTreeNode from '../collapsible-tree/CollapsibleTreeNode.svelte';
import { type LayerTreeType } from '$lib/assets/layers';
import { anySelectedLayer } from './utils.svelte';
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings.svelte';
let { import { type LayerTreeType } from '$lib/assets/layers';
name, import { anySelectedLayer } from './utils';
node,
selected = '', import { _ } from 'svelte-i18n';
onselect = () => {}, import { settings } from '$lib/db';
multiple = false, import { beforeUpdate } from 'svelte';
checked = $bindable({}),
}: { export let name: string;
name: string; export let node: LayerTreeType;
node: LayerTreeType; export let selected: string | undefined = undefined;
selected?: string; export let multiple: boolean = false;
onselect?: (value: string) => void;
multiple: boolean; export let checked: LayerTreeType;
checked: LayerTreeType;
} = $props();
const { customLayers } = settings; const { customLayers } = settings;
$effect.pre(() => { beforeUpdate(() => {
if (checked !== undefined) { if (checked !== undefined) {
Object.keys(node).forEach((id) => { Object.keys(node).forEach((id) => {
if (!checked.hasOwnProperty(id)) { if (!checked.hasOwnProperty(id)) {
@@ -53,7 +46,7 @@
value={id} value={id}
bind:checked={checked[id]} bind:checked={checked[id]}
class="scale-90" class="scale-90"
aria-label={i18n._(`layers.label.${id}`)} aria-label={$_(`layers.label.${id}`)}
/> />
{:else} {:else}
<input <input
@@ -61,48 +54,36 @@
type="radio" type="radio"
{name} {name}
value={id} value={id}
checked={selected === id} bind:group={selected}
oninput={(e) => {
if ((e.target as HTMLInputElement)?.checked) {
onselect(id);
}
}}
/> />
{/if} {/if}
<Label for="{name}-{id}" class="flex flex-row items-center gap-1"> <Label for="{name}-{id}" class="flex flex-row items-center gap-1">
{#if customLayers.value.hasOwnProperty(id)} {#if $customLayers.hasOwnProperty(id)}
{customLayers.value[id].name} {$customLayers[id].name}
{:else} {:else}
{i18n._(`layers.label.${id}`)} {$_(`layers.label.${id}`)}
{/if} {/if}
</Label> </Label>
</div> </div>
{/if} {/if}
{:else if anySelectedLayer(node[id])} {:else if anySelectedLayer(node[id])}
<CollapsibleTreeNode {id}> <CollapsibleTreeNode {id}>
{#snippet trigger()} <span slot="trigger">{$_(`layers.label.${id}`)}</span>
<span>{i18n._(`layers.label.${id}`)}</span> <div slot="content">
{/snippet} <svelte:self
{#snippet content()}
<div class="ml-2">
<Self
node={node[id]} node={node[id]}
{name} {name}
{selected} bind:selected
{onselect}
{multiple} {multiple}
bind:checked={checked[id]} bind:checked={checked[id]}
/> />
</div> </div>
{/snippet}
</CollapsibleTreeNode> </CollapsibleTreeNode>
{/if} {/if}
{/each} {/each}
</div> </div>
<style lang="postcss"> <style lang="postcss">
@reference "../../../../app.css";
div :global(input[type='radio']) { div :global(input[type='radio']) {
@apply appearance-none; @apply appearance-none;
@apply w-4 h-4; @apply w-4 h-4;

View File

@@ -1,12 +1,12 @@
import { SphericalMercator } from '@mapbox/sphericalmercator'; import SphericalMercator from '@mapbox/sphericalmercator';
import { getLayers } from './utils.svelte'; import { getLayers } from './utils';
import { get, writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
import { db, settings } from '$lib/db'; import { db, settings } from '$lib/db';
import { overpassQueryData } from '$lib/assets/layers'; import { overpassQueryData } from '$lib/assets/layers';
import { MapPopup } from '$lib/components/map/map.svelte'; import { MapPopup } from '$lib/components/MapPopup';
// const { currentOverpassQueries } = settings; const { currentOverpassQueries } = settings;
const mercator = new SphericalMercator({ const mercator = new SphericalMercator({
size: 256, size: 256,

View File

@@ -2,8 +2,8 @@
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { selection } from '$lib/components/file-list/Selection'; import { selection } from '$lib/components/file-list/Selection';
import { PencilLine, MapPin } from '@lucide/svelte'; import { PencilLine, MapPin } from 'lucide-svelte';
import { i18n } from '$lib/i18n.svelte'; import { _ } from 'svelte-i18n';
import { dbUtils } from '$lib/db'; import { dbUtils } from '$lib/db';
import type { PopupItem } from '$lib/components/MapPopup'; import type { PopupItem } from '$lib/components/MapPopup';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
@@ -18,7 +18,7 @@
if (tags.name !== undefined && tags.name !== '') { if (tags.name !== undefined && tags.name !== '') {
name = tags.name; name = tags.name;
} else { } else {
name = i18n._(`layers.label.${poi.item.query}`); name = $_(`layers.label.${poi.item.query}`);
} }
} }
@@ -101,7 +101,7 @@
on:click={addToFile} on:click={addToFile}
> >
<MapPin size="16" class="mr-1" /> <MapPin size="16" class="mr-1" />
{i18n._('toolbar.waypoint.add')} {$_('toolbar.waypoint.add')}
</Button> </Button>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>

View File

@@ -1,4 +1,5 @@
import type { LayerTreeType } from '$lib/assets/layers'; import type { LayerTreeType } from '$lib/assets/layers';
import { writable } from 'svelte/store';
export function anySelectedLayer(node: LayerTreeType) { export function anySelectedLayer(node: LayerTreeType) {
return ( return (
@@ -54,6 +55,4 @@ export function toggle(node: LayerTreeType, id: string) {
return node; return node;
} }
export const customBasemapUpdate = $state({ export const customBasemapUpdate = writable(0);
value: 0,
});

View File

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

View File

@@ -1,254 +0,0 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
import { Button } from '$lib/components/ui/button';
import { settings } from '$lib/logic/settings.svelte';
import { i18n } from '$lib/i18n.svelte';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { page } from '$app/state';
import { map } from '$lib/components/map/utils.svelte';
let {
accessToken = PUBLIC_MAPBOX_TOKEN,
geolocate = true,
geocoder = true,
hash = true,
class: className = '',
}: {
accessToken?: string;
geolocate?: boolean;
geocoder?: boolean;
hash?: boolean;
class?: string;
} = $props();
mapboxgl.accessToken = accessToken;
let webgl2Supported = $state(true);
let embeddedApp = $state(false);
const { distanceUnits, elevationProfile, treeFileView, bottomPanelSize, rightPanelSize } =
settings;
onMount(() => {
let gl = document.createElement('canvas').getContext('webgl2');
if (!gl) {
webgl2Supported = false;
return;
}
if (window.top !== window.self && !page.route.id?.includes('embed')) {
embeddedApp = true;
return;
}
let language = page.params.language;
if (language === 'zh') {
language = 'zh-Hans';
} else if (language?.includes('-')) {
language = language.split('-')[0];
} else if (language === '' || language === undefined) {
language = 'en';
}
map.init(PUBLIC_MAPBOX_TOKEN, language, distanceUnits.value, hash, geocoder, geolocate);
});
onDestroy(() => {
map.destroy();
});
$effect(() => {
if (
!treeFileView.value ||
!elevationProfile.value ||
bottomPanelSize.value ||
rightPanelSize.value
) {
map.resize();
}
});
</script>
<div class={className}>
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
<div
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported &&
!embeddedApp
? 'hidden'
: ''} {embeddedApp ? 'z-30' : ''}"
>
{#if !webgl2Supported}
<p>{i18n._('webgl2_required')}</p>
<Button href="https://get.webgl.org/webgl2/" target="_blank">
{i18n._('enable_webgl2')}
</Button>
{:else if embeddedApp}
<p>The app cannot be embedded in an iframe.</p>
<Button href="https://gpx.studio/help/integration" target="_blank">
Learn how to create a map for your website
</Button>
{/if}
</div>
</div>
<style lang="postcss">
@reference "../../../app.css";
div :global(.mapboxgl-map) {
@apply font-sans;
}
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
@apply shadow-md;
@apply bg-background;
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-icon) {
@apply dark:brightness-[4.7];
}
div :global(.mapboxgl-ctrl-geocoder) {
@apply flex;
@apply flex-row;
@apply w-fit;
@apply min-w-fit;
@apply items-center;
@apply shadow-md;
}
div :global(.suggestions) {
@apply shadow-md;
@apply bg-background;
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
@apply text-foreground;
@apply hover:text-accent-foreground;
@apply hover:bg-accent;
}
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
@apply bg-background;
}
div :global(.mapboxgl-ctrl-geocoder--button) {
@apply bg-transparent;
@apply hover:bg-transparent;
}
div :global(.mapboxgl-ctrl-geocoder--icon) {
@apply fill-foreground;
@apply hover:fill-accent-foreground;
}
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
@apply relative;
@apply top-0;
@apply left-0;
@apply my-2;
@apply w-[29px];
}
div :global(.mapboxgl-ctrl-geocoder--input) {
@apply relative;
@apply w-64;
@apply py-0;
@apply pl-2;
@apply focus:outline-none;
@apply transition-[width];
@apply duration-200;
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
@apply w-0;
@apply p-0;
}
div :global(.mapboxgl-ctrl-top-right) {
@apply z-40;
@apply flex;
@apply flex-col;
@apply items-end;
@apply h-full;
@apply overflow-hidden;
}
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
@apply bottom-[42px];
}
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
@apply bottom-[42px];
}
div :global(.mapboxgl-ctrl-attrib) {
@apply dark:bg-transparent;
}
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
@apply dark:bg-background;
}
div :global(.mapboxgl-ctrl-attrib-button) {
@apply dark:bg-foreground;
}
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
@apply dark:bg-foreground;
}
div :global(.mapboxgl-ctrl-attrib a) {
@apply text-foreground;
}
div :global(.mapboxgl-popup) {
@apply w-fit;
@apply z-50;
}
div :global(.mapboxgl-popup-content) {
@apply p-0;
@apply bg-transparent;
@apply shadow-none;
}
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
@apply border-r-background;
}
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
@apply border-l-background;
}
</style>

View File

@@ -1,22 +0,0 @@
<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';
import type { PopupItem } from '$lib/components/map/map.svelte';
let { item, container = null }: { item: PopupItem | null; container: HTMLDivElement | null } =
$props();
</script>
<div bind:this={container}>
{#if item}
{#if item.item instanceof Waypoint}
<WaypointPopup waypoint={item} />
{:else if item.item instanceof TrackPoint}
<TrackpointPopup trackpoint={item} />
{:else}
<OverpassPopup poi={item} />
{/if}
{/if}
</div>

View File

@@ -1,38 +0,0 @@
<script lang="ts">
import CustomControl from './CustomControl';
import { map } from '$lib/components/map/utils.svelte';
import { onMount, type Snippet } from 'svelte';
let {
position = 'top-right',
class: className = '',
children,
}: {
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
class: string;
children: Snippet;
} = $props();
let container: HTMLDivElement;
let control: CustomControl | null = null;
onMount(() => {
map.onLoad((map: mapboxgl.Map) => {
if (position.includes('right')) container.classList.add('float-right');
else container.classList.add('float-left');
container.classList.remove('hidden');
if (control === null) {
control = new CustomControl(container);
}
map.addControl(control, position);
});
});
</script>
<div
bind:this={container}
class="{className ||
''} clear-both translate-0 m-[10px] mb-0 last:mb-[10px] pointer-events-auto bg-background rounded shadow-md hidden"
>
{@render children()}
</div>

View File

@@ -1,30 +0,0 @@
<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';
let {
coordinates,
onCopy = () => {},
class: className = '',
}: {
coordinates: Coordinates;
onCopy: () => void;
class?: string;
} = $props();
</script>
<Button
class="w-full px-2 py-1 h-8 justify-start {className}"
variant="outline"
onclick={() => {
navigator.clipboard.writeText(
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}`
);
onCopy();
}}
>
<ClipboardCopy size="16" class="mr-1" />
{i18n._('menu.copy_coordinates')}
</Button>

View File

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

View File

@@ -1,29 +0,0 @@
<script lang="ts">
import LayerTreeNode from './LayerTreeNode.svelte';
import { type LayerTreeType } from '$lib/assets/layers';
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
let {
layerTree,
name,
selected = '',
onselect = () => {},
multiple = false,
checked = $bindable({}),
}: {
layerTree: LayerTreeType;
name: string;
selected?: string;
onselect?: (value: string) => void;
multiple?: boolean;
checked?: LayerTreeType;
} = $props();
</script>
<form>
<fieldset class="min-w-64 mb-1">
<CollapsibleTree nohover={true}>
<LayerTreeNode {name} node={layerTree} {selected} {onselect} {multiple} bind:checked />
</CollapsibleTree>
</fieldset>
</form>

View File

@@ -1,79 +0,0 @@
<script lang="ts">
import { streetViewEnabled } from '$lib/components/map/street-view-control/utils.svelte';
import { map } from '$lib/components/map/utils.svelte';
import CustomControl from '$lib/components/map/custom-control/CustomControl.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import { Toggle } from '$lib/components/ui/toggle';
import { PersonStanding, X } from '@lucide/svelte';
import { MapillaryLayer } from './Mapillary';
import { GoogleRedirect } from './Google';
import { settings } from '$lib/logic/settings.svelte';
import { i18n } from '$lib/i18n.svelte';
import { onMount } from 'svelte';
const { streetViewSource } = settings;
let googleRedirect: GoogleRedirect | null = $state(null);
let mapillaryLayer: MapillaryLayer | null = $state(null);
let mapillaryOpen = $state({
value: false,
});
let container: HTMLElement;
onMount(() => {
map.onLoad((map: mapboxgl.Map) => {
googleRedirect = new GoogleRedirect(map);
mapillaryLayer = new MapillaryLayer(map, container, mapillaryOpen);
});
});
$effect(() => {
if (streetViewSource.value === 'mapillary') {
googleRedirect?.remove();
if (streetViewEnabled.current) {
mapillaryLayer?.add();
} else {
mapillaryLayer?.remove();
}
} else {
mapillaryLayer?.remove();
if (streetViewEnabled.current) {
googleRedirect?.add();
} else {
googleRedirect?.remove();
}
}
});
</script>
<CustomControl class="w-[29px] h-[29px] shrink-0">
<Tooltip class="w-full h-full" side="left" label={i18n._('menu.toggle_street_view')}>
<Toggle
bind:pressed={streetViewEnabled.current}
class="w-full h-full rounded p-0"
aria-label={i18n._('menu.toggle_street_view')}
>
<PersonStanding size="22" />
</Toggle>
</Tooltip>
</CustomControl>
<div
bind:this={container}
class="{mapillaryOpen.value
? ''
: 'hidden'} !absolute bottom-[44px] right-2.5 z-10 w-[40%] h-[40%] bg-background rounded-md overflow-hidden border-background border-2"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute top-0 right-0 z-10 bg-background p-1 rounded-bl-md cursor-pointer"
onclick={() => {
if (mapillaryLayer) {
mapillaryLayer.closePopup();
}
}}
>
<X size="16" />
</div>
</div>

View File

@@ -1,3 +0,0 @@
export const streetViewEnabled = $state({
current: false,
});

View File

@@ -1,384 +0,0 @@
import { TrackPoint, Waypoint, type Coordinates } from 'gpx';
import mapboxgl from 'mapbox-gl';
import { tick, mount } from 'svelte';
// import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
import { get } from 'svelte/store';
// import { fileObservers } from '$lib/db';
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
let fitBoundsOptions: mapboxgl.MapOptions['fitBoundsOptions'] = {
maxZoom: 15,
linear: true,
easing: () => 1,
};
export class MapboxGLMap {
private _map: mapboxgl.Map | null = $state(null);
private _onLoadCallbacks: ((map: mapboxgl.Map) => void)[] = [];
init(
accessToken: string,
language: string,
distanceUnits: 'metric' | 'imperial' | 'nautical',
hash: boolean,
geocoder: boolean,
geolocate: boolean
) {
const map = new mapboxgl.Map({
container: 'map',
style: {
version: 8,
sources: {},
layers: [],
imports: [
{
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
url: '',
data: {
version: 8,
sources: {},
layers: [],
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${accessToken}`,
},
},
{
id: 'basemap',
url: '',
},
{
id: 'overlays',
url: '',
data: {
version: 8,
sources: {},
layers: [],
},
},
],
},
projection: 'globe',
zoom: 0,
hash: hash,
language,
attributionControl: false,
logoPosition: 'bottom-right',
boxZoom: false,
});
map.addControl(
new mapboxgl.AttributionControl({
compact: true,
})
);
map.addControl(
new mapboxgl.NavigationControl({
visualizePitch: true,
})
);
if (geocoder) {
let geocoder = new MapboxGeocoder({
mapboxgl: mapboxgl,
enableEventLogging: false,
collapsed: true,
flyTo: fitBoundsOptions,
language,
localGeocoder: () => [],
localGeocoderOnly: true,
externalGeocoder: (query: string) =>
fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
)
.then((response) => response.json())
.then((data) => {
return data.map((result: any) => {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [result.lon, result.lat],
},
place_name: result.display_name,
};
});
}),
});
let onKeyDown = geocoder._onKeyDown;
geocoder._onKeyDown = (e: KeyboardEvent) => {
// Trigger search on Enter key only
if (e.key === 'Enter') {
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
} else if (geocoder._typeahead.data.length > 0) {
geocoder._typeahead.clear();
}
};
map.addControl(geocoder);
}
if (geolocate) {
map.addControl(
new mapboxgl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true,
},
fitBoundsOptions,
trackUserLocation: true,
showUserHeading: true,
})
);
}
const scaleControl = new mapboxgl.ScaleControl({
unit: distanceUnits,
});
map.addControl(scaleControl);
map.on('style.load', () => {
map.addSource('mapbox-dem', {
type: 'raster-dem',
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
tileSize: 512,
maxzoom: 14,
});
if (map.getPitch() > 0) {
map.setTerrain({
source: 'mapbox-dem',
exaggeration: 1,
});
}
map.setFog({
color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)',
'horizon-blend': 0.1,
'space-color': 'rgb(156, 240, 255)',
});
map.on('pitch', () => {
if (map.getPitch() > 0) {
map.setTerrain({
source: 'mapbox-dem',
exaggeration: 1,
});
} else {
map.setTerrain(null);
}
});
});
map.on('load', () => {
this._map = map; // only set the store after the map has loaded
window._map = map; // entry point for extensions
scaleControl.setUnit(distanceUnits);
this._onLoadCallbacks.forEach((callback) => callback(map));
this._onLoadCallbacks = [];
});
}
onLoad(callback: (map: mapboxgl.Map) => void) {
if (this._map) {
callback(this._map);
} else {
this._onLoadCallbacks.push(callback);
}
}
destroy() {
if (this._map) {
this._map.remove();
this._map = null;
}
}
get value(): mapboxgl.Map | null {
return this._map;
}
resize() {
if (this._map) {
this._map.resize();
}
}
toggle3D() {
if (this._map) {
if (this._map.getPitch() === 0) {
this._map.easeTo({ pitch: 70 });
} else {
this._map.easeTo({ pitch: 0 });
}
}
}
}
export const map = new MapboxGLMap();
const targetMapBounds: {
bounds: mapboxgl.LngLatBounds;
ids: string[];
total: number;
} = $state({
bounds: new mapboxgl.LngLatBounds([180, 90, -180, -90]),
ids: [],
total: 0,
});
// $effect(() => {
// if (
// map.current === null ||
// targetMapBounds.ids.length > 0 ||
// (targetMapBounds.bounds.getSouth() === 90 &&
// targetMapBounds.bounds.getWest() === 180 &&
// targetMapBounds.bounds.getNorth() === -90 &&
// targetMapBounds.bounds.getEast() === -180)
// ) {
// return;
// }
// let currentZoom = map.current.getZoom();
// let currentBounds = map.current.getBounds();
// if (
// targetMapBounds.total !== get(fileObservers).size &&
// currentBounds &&
// currentZoom > 2 // Extend current bounds only if the map is zoomed in
// ) {
// // There are other files on the map
// if (
// currentBounds.contains(targetMapBounds.bounds.getSouthEast()) &&
// currentBounds.contains(targetMapBounds.bounds.getNorthWest())
// ) {
// return;
// }
// targetMapBounds.bounds.extend(currentBounds.getSouthWest());
// targetMapBounds.bounds.extend(currentBounds.getNorthEast());
// }
// map.current.fitBounds(targetMapBounds.bounds, { padding: 80, linear: true, easing: () => 1 });
// });
export function initTargetMapBounds(ids: string[]) {
targetMapBounds.bounds = new mapboxgl.LngLatBounds([180, 90, -180, -90]);
targetMapBounds.ids = ids;
targetMapBounds.total = ids.length;
}
export function updateTargetMapBounds(
id: string,
bounds: { southWest: Coordinates; northEast: Coordinates }
) {
if (targetMapBounds.ids.indexOf(id) === -1) {
return;
}
if (
bounds.southWest.lat !== 90 ||
bounds.southWest.lon !== 180 ||
bounds.northEast.lat !== -90 ||
bounds.northEast.lon !== -180
) {
// Avoid update for empty (new) files
targetMapBounds.ids = targetMapBounds.ids.filter((x) => x !== id);
targetMapBounds.bounds.extend(bounds.southWest);
targetMapBounds.bounds.extend(bounds.northEast);
}
}
// export function centerMapOnSelection() {
// let selected = get(selection).getSelected();
// let bounds = new mapboxgl.LngLatBounds();
// if (selected.find((item) => item instanceof ListWaypointItem)) {
// applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
// let file = getFile(fileId);
// if (file) {
// items.forEach((item) => {
// if (item instanceof ListWaypointItem) {
// let waypoint = file.wpt[item.getWaypointIndex()];
// if (waypoint) {
// bounds.extend([waypoint.getLongitude(), waypoint.getLatitude()]);
// }
// }
// });
// }
// });
// } else {
// let selectionBounds = get(gpxStatistics).global.bounds;
// bounds.setNorthEast(selectionBounds.northEast);
// bounds.setSouthWest(selectionBounds.southWest);
// }
// get(map)?.fitBounds(bounds, {
// padding: 80,
// easing: () => 1,
// maxZoom: 15,
// });
// }
export type PopupItem<T = Waypoint | TrackPoint | any> = {
item: T;
fileId?: string;
hide?: () => void;
};
// export class MapPopup {
// map: mapboxgl.Map;
// popup: mapboxgl.Popup;
// item: PopupItem | null = $state(null);
// maybeHideBinded = this.maybeHide.bind(this);
// constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
// this.map = map;
// this.popup = new mapboxgl.Popup(options);
// let component = mount(MapPopupComponent, {
// target: document.body,
// props: {
// item: this.item,
// },
// });
// tick().then(() => this.popup.setDOMContent(component.container));
// }
// setItem(item: PopupItem | null) {
// if (item) item.hide = () => this.hide();
// this.item = item;
// if (item === null) {
// this.hide();
// } else {
// tick().then(() => this.show());
// }
// }
// show() {
// if (this.item === null) {
// this.hide();
// return;
// }
// this.popup.setLngLat(this.getCoordinates()).addTo(this.map);
// this.map.on('mousemove', this.maybeHideBinded);
// }
// maybeHide(e: mapboxgl.MapMouseEvent) {
// if (this.item === null) {
// this.hide();
// return;
// }
// if (this.map.project(this.getCoordinates()).dist(this.map.project(e.lngLat)) > 60) {
// this.hide();
// }
// }
// hide() {
// this.popup.remove();
// this.map.off('mousemove', this.maybeHideBinded);
// }
// remove() {
// this.popup.remove();
// }
// getCoordinates() {
// if (this.item === null) {
// return new mapboxgl.LngLat(0, 0);
// }
// return this.item.item instanceof Waypoint || this.item.item instanceof TrackPoint
// ? this.item.item.getCoordinates()
// : new mapboxgl.LngLat(this.item.item.lon, this.item.item.lat);
// }
// }

View File

@@ -2,6 +2,7 @@ import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } fro
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module'; import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
import 'mapillary-js/dist/mapillary.css'; import 'mapillary-js/dist/mapillary.css';
import { resetCursor, setPointerCursor } from '$lib/utils'; import { resetCursor, setPointerCursor } from '$lib/utils';
import type { Writable } from 'svelte/store';
const mapillarySource: VectorSourceSpecification = { const mapillarySource: VectorSourceSpecification = {
type: 'vector', type: 'vector',
@@ -46,13 +47,13 @@ export class MapillaryLayer {
viewer: Viewer; viewer: Viewer;
active = false; active = false;
popupOpen: { value: boolean }; popupOpen: Writable<boolean>;
addBinded = this.add.bind(this); addBinded = this.add.bind(this);
onMouseEnterBinded = this.onMouseEnter.bind(this); onMouseEnterBinded = this.onMouseEnter.bind(this);
onMouseLeaveBinded = this.onMouseLeave.bind(this); onMouseLeaveBinded = this.onMouseLeave.bind(this);
constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: { value: boolean }) { constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: Writable<boolean>) {
this.map = map; this.map = map;
this.viewer = new Viewer({ this.viewer = new Viewer({
@@ -76,7 +77,7 @@ export class MapillaryLayer {
this.viewer.on('position', async () => { this.viewer.on('position', async () => {
if (this.active) { if (this.active) {
popupOpen.value = true; popupOpen.set(true);
let latLng = await this.viewer.getPosition(); let latLng = await this.viewer.getPosition();
this.marker.setLngLat(latLng).addTo(this.map); this.marker.setLngLat(latLng).addTo(this.map);
if (!this.map.getBounds()?.contains(latLng)) { if (!this.map.getBounds()?.contains(latLng)) {
@@ -125,13 +126,13 @@ export class MapillaryLayer {
} }
this.marker.remove(); this.marker.remove();
this.popupOpen.value = false; this.popupOpen.set(false);
} }
closePopup() { closePopup() {
this.active = false; this.active = false;
this.marker.remove(); this.marker.remove();
this.popupOpen.value = false; this.popupOpen.set(false);
} }
onMouseEnter(e: mapboxgl.MapMouseEvent) { onMouseEnter(e: mapboxgl.MapMouseEvent) {

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Tool, tool } from '$lib/components/toolbar/utils.svelte'; import { Tool, currentTool } from '$lib/stores';
import { settings } from '$lib/db';
import { flyAndScale } from '$lib/utils';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte'; import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte'; import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import Waypoint from '$lib/components/toolbar/tools/waypoint/Waypoint.svelte'; import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
import Time from '$lib/components/toolbar/tools/Time.svelte'; import Time from '$lib/components/toolbar/tools/Time.svelte';
import Merge from '$lib/components/toolbar/tools/Merge.svelte'; import Merge from '$lib/components/toolbar/tools/Merge.svelte';
import Extract from '$lib/components/toolbar/tools/Extract.svelte'; import Extract from '$lib/components/toolbar/tools/Extract.svelte';
@@ -13,20 +15,12 @@
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte'; import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import mapboxgl from 'mapbox-gl'; import mapboxgl from 'mapbox-gl';
import { settings } from '$lib/logic/settings.svelte';
let {
popupElement,
popup,
class: className = '',
}: {
popupElement: HTMLDivElement;
popup: mapboxgl.Popup;
class: string;
} = $props();
const { minimizeRoutingMenu } = settings; const { minimizeRoutingMenu } = settings;
let popupElement: HTMLElement;
let popup: mapboxgl.Popup;
onMount(() => { onMount(() => {
popup = new mapboxgl.Popup({ popup = new mapboxgl.Popup({
closeButton: false, closeButton: false,
@@ -37,32 +31,31 @@
}); });
</script> </script>
{#if tool.current !== null} {#if $currentTool !== null}
<div class="translate-x-1 h-full animate-in animate-out {className}"> <div
in:flyAndScale={{ x: -2, y: 0, duration: 100 }}
class="translate-x-1 h-full {$$props.class ?? ''}"
>
<div class="rounded-md shadow-md pointer-events-auto"> <div class="rounded-md shadow-md pointer-events-auto">
<Card.Root class="rounded-md border-none"> <Card.Root class="rounded-md border-none">
<Card.Content class="p-2.5"> <Card.Content class="p-2.5">
{#if tool.current === Tool.ROUTING} {#if $currentTool === Tool.ROUTING}
<Routing <Routing {popup} {popupElement} bind:minimized={$minimizeRoutingMenu} />
{popup} {:else if $currentTool === Tool.SCISSORS}
{popupElement}
bind:minimized={minimizeRoutingMenu.value}
/>
{:else if tool.current === Tool.SCISSORS}
<Scissors /> <Scissors />
{:else if tool.current === Tool.WAYPOINT} {:else if $currentTool === Tool.WAYPOINT}
<Waypoint /> <Waypoint />
{:else if tool.current === Tool.TIME} {:else if $currentTool === Tool.TIME}
<Time /> <Time />
{:else if tool.current === Tool.MERGE} {:else if $currentTool === Tool.MERGE}
<Merge /> <Merge />
{:else if tool.current === Tool.ELEVATION} {:else if $currentTool === Tool.ELEVATION}
<Elevation /> <Elevation />
{:else if tool.current === Tool.EXTRACT} {:else if $currentTool === Tool.EXTRACT}
<Extract /> <Extract />
{:else if tool.current === Tool.CLEAN} {:else if $currentTool === Tool.CLEAN}
<Clean /> <Clean />
{:else if tool.current === Tool.REDUCE} {:else if $currentTool === Tool.REDUCE}
<Reduce /> <Reduce />
{/if} {/if}
</Card.Content> </Card.Content>
@@ -73,8 +66,8 @@
<svelte:window <svelte:window
on:keydown={(e) => { on:keydown={(e) => {
if (tool.current !== null && e.key === 'Escape') { if ($currentTool !== null && e.key === 'Escape') {
tool.current = null; currentTool.set(null);
} }
}} }}
/> />

View File

@@ -11,32 +11,27 @@
import * as RadioGroup from '$lib/components/ui/radio-group'; import * as RadioGroup from '$lib/components/ui/radio-group';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { i18n } from '$lib/i18n.svelte'; import { _, locale } from 'svelte-i18n';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils'; import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
import { Trash2 } from '@lucide/svelte'; import { Trash2 } from 'lucide-svelte';
import { map } from '$lib/components/map/utils.svelte'; import { map } from '$lib/stores';
import type { GeoJSONSource } from 'mapbox-gl'; import { selection } from '$lib/components/file-list/Selection';
import { selection } from '$lib/logic/selection.svelte'; import { dbUtils } from '$lib/db';
import { fileActions } from '$lib/logic/file-actions.svelte';
let props: { let cleanType = CleanType.INSIDE;
class?: string; let deleteTrackpoints = true;
} = $props(); let deleteWaypoints = true;
let rectangleCoordinates: mapboxgl.LngLat[] = [];
let cleanType = $state(CleanType.INSIDE); function updateRectangle() {
let deleteTrackpoints = $state(true); if ($map) {
let deleteWaypoints = $state(true);
let rectangleCoordinates: mapboxgl.LngLat[] = $state([]);
$effect(() => {
if (map.value) {
if (rectangleCoordinates.length != 2) { if (rectangleCoordinates.length != 2) {
if (map.value.getLayer('rectangle')) { if ($map.getLayer('rectangle')) {
map.value.removeLayer('rectangle'); $map.removeLayer('rectangle');
} }
} else { } else {
let data: GeoJSON.Feature = { let data = {
type: 'Feature', type: 'Feature',
geometry: { geometry: {
type: 'Polygon', type: 'Polygon',
@@ -50,19 +45,18 @@
], ],
], ],
}, },
properties: {},
}; };
let source: GeoJSONSource | undefined = map.value.getSource('rectangle'); let source = $map.getSource('rectangle');
if (source) { if (source) {
source.setData(data); source.setData(data);
} else { } else {
map.value.addSource('rectangle', { $map.addSource('rectangle', {
type: 'geojson', type: 'geojson',
data: data, data: data,
}); });
} }
if (!map.value.getLayer('rectangle')) { if (!$map.getLayer('rectangle')) {
map.value.addLayer({ $map.addLayer({
id: 'rectangle', id: 'rectangle',
type: 'fill', type: 'fill',
source: 'rectangle', source: 'rectangle',
@@ -74,7 +68,11 @@
} }
} }
} }
}); }
$: if (rectangleCoordinates) {
updateRectangle();
}
let mousedown = false; let mousedown = false;
function onMouseDown(e: any) { function onMouseDown(e: any) {
@@ -93,63 +91,64 @@
} }
onMount(() => { onMount(() => {
if (map.value) { setCrosshairCursor();
setCrosshairCursor(map.value.getCanvas());
map.value.on('mousedown', onMouseDown);
map.value.on('mousemove', onMouseMove);
map.value.on('mouseup', onMouseUp);
map.value.on('touchstart', onMouseDown);
map.value.on('touchmove', onMouseMove);
map.value.on('touchend', onMouseUp);
map.value.dragPan.disable();
}
}); });
$: if ($map) {
$map.on('mousedown', onMouseDown);
$map.on('mousemove', onMouseMove);
$map.on('mouseup', onMouseUp);
$map.on('touchstart', onMouseDown);
$map.on('touchmove', onMouseMove);
$map.on('touchend', onMouseUp);
$map.dragPan.disable();
}
onDestroy(() => { onDestroy(() => {
if (map.value) { resetCursor();
resetCursor(map.value.getCanvas()); if ($map) {
map.value.off('mousedown', onMouseDown); $map.off('mousedown', onMouseDown);
map.value.off('mousemove', onMouseMove); $map.off('mousemove', onMouseMove);
map.value.off('mouseup', onMouseUp); $map.off('mouseup', onMouseUp);
map.value.off('touchstart', onMouseDown); $map.off('touchstart', onMouseDown);
map.value.off('touchmove', onMouseMove); $map.off('touchmove', onMouseMove);
map.value.off('touchend', onMouseUp); $map.off('touchend', onMouseUp);
map.value.dragPan.enable(); $map.dragPan.enable();
if (map.value.getLayer('rectangle')) { if ($map.getLayer('rectangle')) {
map.value.removeLayer('rectangle'); $map.removeLayer('rectangle');
} }
if (map.value.getSource('rectangle')) { if ($map.getSource('rectangle')) {
map.value.removeSource('rectangle'); $map.removeSource('rectangle');
} }
} }
}); });
let validSelection = $derived(selection.value.size > 0); $: validSelection = $selection.size > 0;
</script> </script>
<div class="flex flex-col gap-3 w-full max-w-80 items-center {props.class ?? ''}"> <div class="flex flex-col gap-3 w-full max-w-80 items-center {$$props.class ?? ''}">
<fieldset class="flex flex-col gap-3"> <fieldset class="flex flex-col gap-3">
<div class="flex flex-row items-center gap-[6.4px] h-3"> <div class="flex flex-row items-center gap-[6.4px] h-3">
<Checkbox id="delete-trkpt" bind:checked={deleteTrackpoints} class="scale-90" /> <Checkbox id="delete-trkpt" bind:checked={deleteTrackpoints} class="scale-90" />
<Label for="delete-trkpt"> <Label for="delete-trkpt">
{i18n._('toolbar.clean.delete_trackpoints')} {$_('toolbar.clean.delete_trackpoints')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-[6.4px] h-3"> <div class="flex flex-row items-center gap-[6.4px] h-3">
<Checkbox id="delete-wpt" bind:checked={deleteWaypoints} class="scale-90" /> <Checkbox id="delete-wpt" bind:checked={deleteWaypoints} class="scale-90" />
<Label for="delete-wpt"> <Label for="delete-wpt">
{i18n._('toolbar.clean.delete_waypoints')} {$_('toolbar.clean.delete_waypoints')}
</Label> </Label>
</div> </div>
<RadioGroup.Root bind:value={cleanType}> <RadioGroup.Root bind:value={cleanType}>
<Label class="flex flex-row items-center gap-2"> <Label class="flex flex-row items-center gap-2">
<RadioGroup.Item value={CleanType.INSIDE} /> <RadioGroup.Item value={CleanType.INSIDE} />
{i18n._('toolbar.clean.delete_inside')} {$_('toolbar.clean.delete_inside')}
</Label> </Label>
<Label class="flex flex-row items-center gap-2"> <Label class="flex flex-row items-center gap-2">
<RadioGroup.Item value={CleanType.OUTSIDE} /> <RadioGroup.Item value={CleanType.OUTSIDE} />
{i18n._('toolbar.clean.delete_outside')} {$_('toolbar.clean.delete_outside')}
</Label> </Label>
</RadioGroup.Root> </RadioGroup.Root>
</fieldset> </fieldset>
@@ -157,8 +156,8 @@
variant="outline" variant="outline"
class="w-full" class="w-full"
disabled={!validSelection || rectangleCoordinates.length != 2} disabled={!validSelection || rectangleCoordinates.length != 2}
onclick={() => { on:click={() => {
fileActions.cleanSelection( dbUtils.cleanSelection(
[ [
{ {
lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat), lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
@@ -177,13 +176,13 @@
}} }}
> >
<Trash2 size="16" class="mr-1" /> <Trash2 size="16" class="mr-1" />
{i18n._('toolbar.clean.button')} {$_('toolbar.clean.button')}
</Button> </Button>
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/clean')}> <Help link={getURLForLanguage($locale, '/help/toolbar/clean')}>
{#if validSelection} {#if validSelection}
{i18n._('toolbar.clean.help')} {$_('toolbar.clean.help')}
{:else} {:else}
{i18n._('toolbar.clean.help_no_selection')} {$_('toolbar.clean.help_no_selection')}
{/if} {/if}
</Help> </Help>
</div> </div>

View File

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

View File

@@ -1,27 +1,22 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Ungroup } from '@lucide/svelte'; import { Ungroup } from 'lucide-svelte';
import { selection } from '$lib/components/file-list/Selection';
import { import {
ListFileItem, ListFileItem,
ListTrackItem, ListTrackItem,
ListTrackSegmentItem, ListTrackSegmentItem,
ListWaypointItem, ListWaypointItem,
ListWaypointsItem, ListWaypointsItem,
} from '$lib/components/file-list/file-list'; } from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { i18n } from '$lib/i18n.svelte'; import { dbUtils, getFile } from '$lib/db';
import { _, locale } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection.svelte';
import { fileStateCollection } from '$lib/logic/file-state.svelte';
import { fileActions } from '$lib/logic/file-actions.svelte';
let props: { $: validSelection =
class?: string; $selection.size > 0 &&
} = $props(); $selection.getSelected().every((item) => {
let validSelection = $derived(
selection.value.size > 0 &&
selection.value.getSelected().every((item) => {
if ( if (
item instanceof ListWaypointsItem || item instanceof ListWaypointsItem ||
item instanceof ListWaypointItem || item instanceof ListWaypointItem ||
@@ -29,7 +24,7 @@
) { ) {
return false; return false;
} }
let file = fileStateCollection.getFile(item.getFileId()); let file = getFile(item.getFileId());
if (file) { if (file) {
if (item instanceof ListFileItem) { if (item instanceof ListFileItem) {
return file.getSegments().length > 1; return file.getSegments().length > 1;
@@ -40,20 +35,19 @@
} }
} }
return false; return false;
}) });
);
</script> </script>
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}"> <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} on:click={dbUtils.extractSelection}>
<Ungroup size="16" class="mr-1" /> <Ungroup size="16" class="mr-1" />
{i18n._('toolbar.extract.button')} {$_('toolbar.extract.button')}
</Button> </Button>
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/extract')}> <Help link={getURLForLanguage($locale, '/help/toolbar/extract')}>
{#if validSelection} {#if validSelection}
{i18n._('toolbar.extract.help')} {$_('toolbar.extract.help')}
{:else} {:else}
{i18n._('toolbar.extract.help_invalid_selection')} {$_('toolbar.extract.help_invalid_selection')}
{/if} {/if}
</Help> </Help>
</div> </div>

View File

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

View File

@@ -2,37 +2,33 @@
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Slider } from '$lib/components/ui/slider'; import { Slider } from '$lib/components/ui/slider';
import { selection } from '$lib/components/file-list/Selection';
import { import {
ListItem, ListItem,
ListRootItem, ListRootItem,
ListTrackSegmentItem, ListTrackSegmentItem,
} from '$lib/components/file-list/file-list'; } from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { Funnel } from '@lucide/svelte'; import { Filter } from 'lucide-svelte';
import { i18n } from '$lib/i18n.svelte'; import { _, locale } from 'svelte-i18n';
import WithUnits from '$lib/components/WithUnits.svelte'; import WithUnits from '$lib/components/WithUnits.svelte';
import { map } from '$lib/components/map/utils.svelte'; import { dbUtils, fileObservers } from '$lib/db';
import { map } from '$lib/stores';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx'; import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
import { derived } from 'svelte/store';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import type { GeoJSONSource } from 'mapbox-gl';
import { selection } from '$lib/logic/selection.svelte';
import { fileActions } from '$lib/logic/file-actions.svelte';
let props: { class?: string } = $props(); let sliderValue = [50];
let maxPoints = 0;
let sliderValue = $state([50]); let currentPoints = 0;
let maxPoints = $state(0);
let currentPoints = $state(0);
const minTolerance = 0.1; const minTolerance = 0.1;
const maxTolerance = 10000; const maxTolerance = 10000;
let validSelection = $derived( $: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
selection.value.hasAnyChildren(new ListRootItem(), true, ['waypoints'])
); $: tolerance =
let tolerance = $derived( minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)));
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)))
);
let simplified = new Map<string, [ListItem, number, SimplifiedTrackPoint[]]>(); let simplified = new Map<string, [ListItem, number, SimplifiedTrackPoint[]]>();
let unsubscribes = new Map<string, () => void>(); let unsubscribes = new Map<string, () => void>();
@@ -67,18 +63,18 @@
}); });
}); });
if (map.value) { if ($map) {
let source: GeoJSONSource | undefined = map.value.getSource('simplified'); let source = $map.getSource('simplified');
if (source) { if (source) {
source.setData(data); source.setData(data);
} else { } else {
map.value.addSource('simplified', { $map.addSource('simplified', {
type: 'geojson', type: 'geojson',
data: data, data: data,
}); });
} }
if (!map.value.getLayer('simplified')) { if (!$map.getLayer('simplified')) {
map.value.addLayer({ $map.addLayer({
id: 'simplified', id: 'simplified',
type: 'line', type: 'line',
source: 'simplified', source: 'simplified',
@@ -88,66 +84,62 @@
}, },
}); });
} else { } else {
map.value.moveLayer('simplified'); $map.moveLayer('simplified');
} }
} }
} }
// $effect(() => { $: if ($fileObservers) {
// if ($fileObservers) { unsubscribes.forEach((unsubscribe, fileId) => {
// unsubscribes.forEach((unsubscribe, fileId) => { if (!$fileObservers.has(fileId)) {
// if (!$fileObservers.has(fileId)) { unsubscribe();
// unsubscribe(); unsubscribes.delete(fileId);
// unsubscribes.delete(fileId); }
// } });
// }); $fileObservers.forEach((fileStore, fileId) => {
// $fileObservers.forEach((fileStore, fileId) => { if (!unsubscribes.has(fileId)) {
// if (!unsubscribes.has(fileId)) { let unsubscribe = derived([fileStore, selection], ([fs, sel]) => [
// let unsubscribe = derived([fileStore, selection], ([fs, sel]) => [ fs,
// fs, sel,
// sel, ]).subscribe(([fs, sel]) => {
// ]).subscribe(([fs, sel]) => { if (fs) {
// if (fs) { fs.file.forEachSegment((segment, trackIndex, segmentIndex) => {
// fs.file.forEachSegment((segment, trackIndex, segmentIndex) => { let segmentItem = new ListTrackSegmentItem(
// let segmentItem = new ListTrackSegmentItem( fileId,
// fileId, trackIndex,
// trackIndex, segmentIndex
// segmentIndex );
// ); if (sel.hasAnyParent(segmentItem)) {
// if (sel.hasAnyParent(segmentItem)) { let statistics = fs.statistics.getStatisticsFor(segmentItem);
// let statistics = fs.statistics.getStatisticsFor(segmentItem); simplified.set(segmentItem.getFullId(), [
// simplified.set(segmentItem.getFullId(), [ segmentItem,
// segmentItem, statistics.local.points.length,
// statistics.local.points.length, ramerDouglasPeucker(statistics.local.points, minTolerance),
// ramerDouglasPeucker(statistics.local.points, minTolerance), ]);
// ]); update();
// update(); } else if (simplified.has(segmentItem.getFullId())) {
// } else if (simplified.has(segmentItem.getFullId())) { simplified.delete(segmentItem.getFullId());
// simplified.delete(segmentItem.getFullId());
// update();
// }
// });
// }
// });
// unsubscribes.set(fileId, unsubscribe);
// }
// });
// }
// });
$effect(() => {
if (tolerance) {
update(); update();
} }
}); });
}
});
unsubscribes.set(fileId, unsubscribe);
}
});
}
$: if (tolerance) {
update();
}
onDestroy(() => { onDestroy(() => {
if (map.value) { if ($map) {
if (map.value.getLayer('simplified')) { if ($map.getLayer('simplified')) {
map.value.removeLayer('simplified'); $map.removeLayer('simplified');
} }
if (map.value.getSource('simplified')) { if ($map.getSource('simplified')) {
map.value.removeSource('simplified'); $map.removeSource('simplified');
} }
} }
unsubscribes.forEach((unsubscribe) => unsubscribe()); unsubscribes.forEach((unsubscribe) => unsubscribe());
@@ -164,32 +156,32 @@
.map((point) => point.point) .map((point) => point.point)
); );
}); });
fileActions.reduce(itemsAndPoints); dbUtils.reduce(itemsAndPoints);
} }
</script> </script>
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}"> <div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
<div class="p-2"> <div class="p-2">
<Slider bind:value={sliderValue} min={0} max={100} step={1} type="multiple" /> <Slider bind:value={sliderValue} min={0} max={100} step={1} />
</div> </div>
<Label class="flex flex-row justify-between"> <Label class="flex flex-row justify-between">
<span>{i18n._('toolbar.reduce.tolerance')}</span> <span>{$_('toolbar.reduce.tolerance')}</span>
<WithUnits value={tolerance / 1000} type="distance" decimals={4} class="font-normal" /> <WithUnits value={tolerance / 1000} type="distance" decimals={4} class="font-normal" />
</Label> </Label>
<Label class="flex flex-row justify-between"> <Label class="flex flex-row justify-between">
<span>{i18n._('toolbar.reduce.number_of_points')}</span> <span>{$_('toolbar.reduce.number_of_points')}</span>
<span class="font-normal">{currentPoints}/{maxPoints}</span> <span class="font-normal">{currentPoints}/{maxPoints}</span>
</Label> </Label>
<Button variant="outline" disabled={!validSelection} onclick={reduce}> <Button variant="outline" disabled={!validSelection} on:click={reduce}>
<Funnel size="16" class="mr-1" /> <Filter size="16" class="mr-1" />
{i18n._('toolbar.reduce.button')} {$_('toolbar.reduce.button')}
</Button> </Button>
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/minify')}> <Help link={getURLForLanguage($locale, '/help/toolbar/minify')}>
{#if validSelection} {#if validSelection}
{i18n._('toolbar.reduce.help')} {$_('toolbar.reduce.help')}
{:else} {:else}
{i18n._('toolbar.reduce.help_no_selection')} {$_('toolbar.reduce.help_no_selection')}
{/if} {/if}
</Help> </Help>
</div> </div>

View File

@@ -5,6 +5,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import TimePicker from '$lib/components/ui/time-picker/TimePicker.svelte'; import TimePicker from '$lib/components/ui/time-picker/TimePicker.svelte';
import { dbUtils, settings } from '$lib/db';
import { gpxStatistics } from '$lib/stores'; import { gpxStatistics } from '$lib/stores';
import { import {
distancePerHourToSecondsPerDistance, distancePerHourToSecondsPerDistance,
@@ -13,25 +14,19 @@
nauticalMilesToKilometers, nauticalMilesToKilometers,
} from '$lib/units'; } from '$lib/units';
import { CalendarDate, type DateValue } from '@internationalized/date'; import { CalendarDate, type DateValue } from '@internationalized/date';
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from '@lucide/svelte'; import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { i18n } from '$lib/i18n.svelte'; import { _, locale } from 'svelte-i18n';
import { get } from 'svelte/store';
import { selection } from '$lib/components/file-list/Selection';
import { import {
ListFileItem, ListFileItem,
ListRootItem, ListRootItem,
ListTrackItem, ListTrackItem,
ListTrackSegmentItem, ListTrackSegmentItem,
} from '$lib/components/file-list/file-list'; } from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection.svelte';
import { settings } from '$lib/logic/settings.svelte';
import { fileActions } from '$lib/logic/file-actions.svelte';
import { fileActionManager } from '$lib/logic/file-action-manager.svelte';
let props: {
class?: string;
} = $props();
let startDate: DateValue | undefined = undefined; let startDate: DateValue | undefined = undefined;
let startTime: string | undefined = undefined; let startTime: string | undefined = undefined;
@@ -53,7 +48,7 @@
function setSpeed(value: number) { function setSpeed(value: number) {
let speedValue = getConvertedVelocity(value); let speedValue = getConvertedVelocity(value);
if (velocityUnits.value === 'speed') { if ($velocityUnits === 'speed') {
speedValue = parseFloat(speedValue.toFixed(2)); speedValue = parseFloat(speedValue.toFixed(2));
} }
speed = speedValue; speed = speedValue;
@@ -86,9 +81,9 @@
} }
} }
// $: if ($gpxStatistics && $velocityUnits && $distanceUnits) { $: if ($gpxStatistics && $velocityUnits && $distanceUnits) {
// setGPXData(); setGPXData();
// } }
function getDate(date: DateValue, time: string): Date { function getDate(date: DateValue, time: string): Date {
if (date === undefined) { if (date === undefined) {
@@ -139,12 +134,12 @@
} }
let speedValue = speed; let speedValue = speed;
if (velocityUnits.value === 'pace') { if ($velocityUnits === 'pace') {
speedValue = distancePerHourToSecondsPerDistance(speed); speedValue = distancePerHourToSecondsPerDistance(speed);
} }
if (distanceUnits.value === 'imperial') { if ($distanceUnits === 'imperial') {
speedValue = milesToKilometers(speedValue); speedValue = milesToKilometers(speedValue);
} else if (distanceUnits.value === 'nautical') { } else if ($distanceUnits === 'nautical') {
speedValue = nauticalMilesToKilometers(speedValue); speedValue = nauticalMilesToKilometers(speedValue);
} }
return speedValue; return speedValue;
@@ -177,26 +172,24 @@
updateEnd(); updateEnd();
} }
let canUpdate = $derived( $: canUpdate =
selection.value.size === 1 && $selection.size === 1 && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
selection.value.hasAnyChildren(new ListRootItem(), true, ['waypoints'])
);
</script> </script>
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}"> <div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
<fieldset class="flex flex-col gap-2"> <fieldset class="flex flex-col gap-2">
<div class="flex flex-row gap-2 justify-center"> <div class="flex flex-row gap-2 justify-center">
<div class="flex flex-col gap-2 grow"> <div class="flex flex-col gap-2 grow">
<Label for="speed" class="flex flex-row"> <Label for="speed" class="flex flex-row">
<Zap size="16" class="mr-1" /> <Zap size="16" class="mr-1" />
{#if velocityUnits.value === 'speed'} {#if $velocityUnits === 'speed'}
{i18n._('quantities.speed')} {$_('quantities.speed')}
{:else} {:else}
{i18n._('quantities.pace')} {$_('quantities.pace')}
{/if} {/if}
</Label> </Label>
<div class="flex flex-row gap-1 items-center"> <div class="flex flex-row gap-1 items-center">
{#if velocityUnits.value === 'speed'} {#if $velocityUnits === 'speed'}
<Input <Input
id="speed" id="speed"
type="number" type="number"
@@ -204,15 +197,15 @@
min={0.01} min={0.01}
disabled={!canUpdate} disabled={!canUpdate}
bind:value={speed} bind:value={speed}
onchange={updateDataFromSpeed} on:change={updateDataFromSpeed}
/> />
<span class="text-sm shrink-0"> <span class="text-sm shrink-0">
{#if distanceUnits.value === 'imperial'} {#if $distanceUnits === 'imperial'}
{i18n._('units.miles_per_hour')} {$_('units.miles_per_hour')}
{:else if distanceUnits.value === 'metric'} {:else if $distanceUnits === 'metric'}
{i18n._('units.kilometers_per_hour')} {$_('units.kilometers_per_hour')}
{:else if distanceUnits.value === 'nautical'} {:else if $distanceUnits === 'nautical'}
{i18n._('units.knots')} {$_('units.knots')}
{/if} {/if}
</span> </span>
{:else} {:else}
@@ -223,12 +216,12 @@
onChange={updateDataFromSpeed} onChange={updateDataFromSpeed}
/> />
<span class="text-sm shrink-0"> <span class="text-sm shrink-0">
{#if distanceUnits.value === 'imperial'} {#if $distanceUnits === 'imperial'}
{i18n._('units.minutes_per_mile')} {$_('units.minutes_per_mile')}
{:else if distanceUnits.value === 'metric'} {:else if $distanceUnits === 'metric'}
{i18n._('units.minutes_per_kilometer')} {$_('units.minutes_per_kilometer')}
{:else if distanceUnits.value === 'nautical'} {:else if $distanceUnits === 'nautical'}
{i18n._('units.minutes_per_nautical_mile')} {$_('units.minutes_per_nautical_mile')}
{/if} {/if}
</span> </span>
{/if} {/if}
@@ -237,7 +230,7 @@
<div class="flex flex-col gap-2 grow"> <div class="flex flex-col gap-2 grow">
<Label for="duration" class="flex flex-row"> <Label for="duration" class="flex flex-row">
<Timer size="16" class="mr-1" /> <Timer size="16" class="mr-1" />
{i18n._('toolbar.time.total_time')} {$_('toolbar.time.total_time')}
</Label> </Label>
<TimePicker <TimePicker
bind:value={movingTime} bind:value={movingTime}
@@ -248,14 +241,14 @@
</div> </div>
<Label class="flex flex-row"> <Label class="flex flex-row">
<CirclePlay size="16" class="mr-1" /> <CirclePlay size="16" class="mr-1" />
{i18n._('toolbar.time.start')} {$_('toolbar.time.start')}
</Label> </Label>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<DatePicker <DatePicker
bind:value={startDate} bind:value={startDate}
disabled={!canUpdate} disabled={!canUpdate}
locale={i18n.lang} locale={get(locale) ?? 'en'}
placeholder={i18n._('toolbar.time.pick_date')} placeholder={$_('toolbar.time.pick_date')}
class="w-fit grow" class="w-fit grow"
onValueChange={async () => { onValueChange={async () => {
await tick(); await tick();
@@ -268,19 +261,19 @@
disabled={!canUpdate} disabled={!canUpdate}
bind:value={startTime} bind:value={startTime}
class="w-fit" class="w-fit"
onchange={updateEnd} on:change={updateEnd}
/> />
</div> </div>
<Label class="flex flex-row"> <Label class="flex flex-row">
<CircleStop size="16" class="mr-1" /> <CircleStop size="16" class="mr-1" />
{i18n._('toolbar.time.end')} {$_('toolbar.time.end')}
</Label> </Label>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<DatePicker <DatePicker
bind:value={endDate} bind:value={endDate}
disabled={!canUpdate} disabled={!canUpdate}
locale={i18n.lang} locale={get(locale) ?? 'en'}
placeholder={i18n._('toolbar.time.pick_date')} placeholder={$_('toolbar.time.pick_date')}
class="w-fit grow" class="w-fit grow"
onValueChange={async () => { onValueChange={async () => {
await tick(); await tick();
@@ -293,14 +286,14 @@
disabled={!canUpdate} disabled={!canUpdate}
bind:value={endTime} bind:value={endTime}
class="w-fit" class="w-fit"
onchange={updateStart} on:change={updateStart}
/> />
</div> </div>
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined} {#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
<div class="mt-0.5 flex flex-row gap-1 items-center"> <div class="mt-0.5 flex flex-row gap-1 items-center">
<Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} /> <Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} />
<Label for="artificial-time"> <Label for="artificial-time">
{i18n._('toolbar.time.artificial')} {$_('toolbar.time.artificial')}
</Label> </Label>
</div> </div>
{/if} {/if}
@@ -310,7 +303,7 @@
variant="outline" variant="outline"
disabled={!canUpdate} disabled={!canUpdate}
class="grow whitespace-normal h-fit" class="grow whitespace-normal h-fit"
onclick={() => { on:click={() => {
let effectiveSpeed = getSpeed(); let effectiveSpeed = getSpeed();
if ( if (
startDate === undefined || startDate === undefined ||
@@ -332,9 +325,9 @@
ratio = $gpxStatistics.global.speed.moving / effectiveSpeed; ratio = $gpxStatistics.global.speed.moving / effectiveSpeed;
} }
let item = selection.value.getSelected()[0]; let item = $selection.getSelected()[0];
let fileId = item.getFileId(); let fileId = item.getFileId();
fileActionManager.applyToFile(fileId, (file) => { dbUtils.applyToFile(fileId, (file) => {
if (item instanceof ListFileItem) { if (item instanceof ListFileItem) {
if (artificial || !$gpxStatistics.global.time.moving) { if (artificial || !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps( file.createArtificialTimestamps(
@@ -385,24 +378,22 @@
}} }}
> >
<CalendarClock size="16" class="mr-1 shrink-0" /> <CalendarClock size="16" class="mr-1 shrink-0" />
{i18n._('toolbar.time.update')} {$_('toolbar.time.update')}
</Button> </Button>
<Button variant="outline" onclick={setGPXData}> <Button variant="outline" on:click={setGPXData}>
<CircleX size="16" /> <CircleX size="16" />
</Button> </Button>
</div> </div>
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/time')}> <Help link={getURLForLanguage($locale, '/help/toolbar/time')}>
{#if canUpdate} {#if canUpdate}
{i18n._('toolbar.time.help')} {$_('toolbar.time.help')}
{:else} {:else}
{i18n._('toolbar.time.help_invalid_selection')} {$_('toolbar.time.help_invalid_selection')}
{/if} {/if}
</Help> </Help>
</div> </div>
<style lang="postcss"> <style lang="postcss">
@reference "../../../../app.css";
div :global(input[type='time']) { div :global(input[type='time']) {
/* /*
Style copy-pasted from shadcn-svelte Input. Style copy-pasted from shadcn-svelte Input.

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import type { Coordinates } from 'gpx'; import type { Coordinates } from 'gpx';
import { TrackPoint, distance } from 'gpx'; import { TrackPoint, distance } from 'gpx';
import { settings } from '$lib/logic/settings.svelte'; import { derived, get, writable } from 'svelte/store';
import { settings } from '$lib/db';
import { _, isLoading, locale } from 'svelte-i18n';
import { getElevation } from '$lib/utils'; import { getElevation } from '$lib/utils';
const { routing, routingProfile, privateRoads } = settings; const { routing, routingProfile, privateRoads } = settings;
@@ -15,10 +17,38 @@ export const brouterProfiles: { [key: string]: string } = {
water: 'river', water: 'river',
railway: 'rail', railway: 'rail',
}; };
export const routingProfileSelectItem = writable({
value: '',
label: '',
});
derived([routingProfile, locale, isLoading], ([profile, l, i]) => [profile, l, i]).subscribe(
([profile, l, i]) => {
if (
!i &&
profile !== '' &&
(profile !== get(routingProfileSelectItem).value ||
get(_)(`toolbar.routing.activities.${profile}`) !==
get(routingProfileSelectItem).label) &&
l !== null
) {
routingProfileSelectItem.update((item) => {
item.value = profile;
item.label = get(_)(`toolbar.routing.activities.${profile}`);
return item;
});
}
}
);
routingProfileSelectItem.subscribe((item) => {
if (item.value !== '' && item.value !== get(routingProfile)) {
routingProfile.set(item.value);
}
});
export function route(points: Coordinates[]): Promise<TrackPoint[]> { export function route(points: Coordinates[]): Promise<TrackPoint[]> {
if (routing.value) { if (get(routing)) {
return getRoute(points, brouterProfiles[routingProfile.value], privateRoads.value); return getRoute(points, brouterProfiles[get(routingProfile)], get(privateRoads));
} else { } else {
return getIntermediatePoints(points); return getIntermediatePoints(points);
} }

View File

@@ -3,9 +3,9 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Shortcut from '$lib/components/Shortcut.svelte'; import Shortcut from '$lib/components/Shortcut.svelte';
import { canChangeStart } from './RoutingControls'; import { canChangeStart } from './RoutingControls';
import { CirclePlay, Trash2 } from '@lucide/svelte'; import { CirclePlay, Trash2 } from 'lucide-svelte';
import { i18n } from '$lib/i18n.svelte'; import { _ } from 'svelte-i18n';
export let element: HTMLElement; export let element: HTMLElement;
</script> </script>
@@ -17,19 +17,19 @@
<Button <Button
class="w-full px-2 py-1 h-6 justify-start" class="w-full px-2 py-1 h-6 justify-start"
variant="ghost" variant="ghost"
onclick={() => element.dispatchEvent(new CustomEvent('change-start'))} on:click={() => element.dispatchEvent(new CustomEvent('change-start'))}
> >
<CirclePlay size="16" class="mr-1" /> <CirclePlay size="16" class="mr-1" />
{i18n._('toolbar.routing.start_loop_here')} {$_('toolbar.routing.start_loop_here')}
</Button> </Button>
{/if} {/if}
<Button <Button
class="w-full px-2 py-1 h-6 justify-start" class="w-full px-2 py-1 h-6 justify-start"
variant="ghost" variant="ghost"
onclick={() => element.dispatchEvent(new CustomEvent('delete'))} on:click={() => element.dispatchEvent(new CustomEvent('delete'))}
> >
<Trash2 size="16" class="mr-1" /> <Trash2 size="16" class="mr-1" />
{i18n._('menu.delete')} {$_('menu.delete')}
<Shortcut shift={true} click={true} /> <Shortcut shift={true} click={true} />
</Button> </Button>
</Card.Content> </Card.Content>

View File

@@ -1,17 +1,20 @@
import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from 'gpx'; import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from 'gpx';
import { get, writable, type Readable } from 'svelte/store'; import { get, writable, type Readable } from 'svelte/store';
import mapboxgl from 'mapbox-gl'; import mapboxgl from 'mapbox-gl';
import { route } from './utils.svelte'; import { route } from './Routing';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { _ } from 'svelte-i18n';
import { dbUtils, settings, type GPXFileWithStatistics } from '$lib/db';
import { getOrderedSelection, selection } from '$lib/components/file-list/Selection';
import { import {
ListFileItem, ListFileItem,
ListTrackItem, ListTrackItem,
ListTrackSegmentItem, ListTrackSegmentItem,
} from '$lib/components/file-list/file-list'; } from '$lib/components/file-list/FileList';
import { currentTool, streetViewEnabled, Tool } from '$lib/stores';
import { getClosestLinePoint, resetCursor, setGrabbingCursor } from '$lib/utils'; import { getClosestLinePoint, resetCursor, setGrabbingCursor } from '$lib/utils';
import type { GPXFileWithStatistics } from '$lib/logic/statistics';
// const { streetViewSource } = settings; const { streetViewSource } = settings;
export const canChangeStart = writable(false); export const canChangeStart = writable(false);
function stopPropagation(e: any) { function stopPropagation(e: any) {

View File

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

Some files were not shown because too many files have changed in this diff Show More