93 Commits

Author SHA1 Message Date
vcoppe
0bf168e67e Merge branch 'dev' 2026-04-09 21:10:56 +02:00
vcoppe
7e9140492a fix max zoom 2026-04-09 21:10:45 +02:00
vcoppe
d762a45eb9 Merge branch 'dev' 2026-04-09 20:57:30 +02:00
vcoppe
79c0aed54f New Crowdin updates (#323)
* New translations en.json (Basque)

* New translations en.json (Dutch)

* New translations en.json (Catalan)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Belarusian)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Thai)

* New translations en.json (Latvian)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Serbian (Latin))
2026-04-09 20:57:02 +02:00
vcoppe
cb5a74de00 add esri satellite 2026-04-09 19:53:53 +02:00
vcoppe
31f25f346a New Crowdin updates (#322)
* New translations en.json (Ukrainian)

* New translations en.json (Ukrainian)

* New translations files-and-stats.mdx (Ukrainian)

* New translations getting-started.mdx (Ukrainian)

* New translations edit.mdx (Ukrainian)

* New translations view.mdx (Ukrainian)

* New translations en.json (Basque)
2026-04-09 19:33:42 +02:00
vcoppe
548ab9a459 Merge branch 'dev' 2026-04-07 22:19:15 +02:00
vcoppe
b3a11125a5 adapt temporary anchor layer 2026-04-07 22:19:03 +02:00
vcoppe
71cdc03da5 file specific routing controls layers 2026-04-07 22:15:17 +02:00
vcoppe
315c1f6a61 Merge branch 'dev' 2026-04-07 22:02:14 +02:00
vcoppe
694e73a677 reinit shadcn-svelte, fixes #318 2026-04-07 22:01:58 +02:00
vcoppe
98257bee12 Merge branch 'dev' 2026-04-06 18:26:22 +02:00
vcoppe
5aaacccef9 update components 2026-04-06 18:22:01 +02:00
vcoppe
f2bf043900 fix button size 2026-04-06 15:05:47 +02:00
vcoppe
ba251fe407 update date picker with language 2026-04-06 15:04:13 +02:00
vcoppe
5561b7d9fe Merge branch 'dev' 2026-04-06 14:26:56 +02:00
vcoppe
421aa9dc69 remove now unused package 2026-04-06 14:26:19 +02:00
vcoppe
320887206c Merge branch 'dev' 2026-04-04 23:15:01 +02:00
vcoppe
b4a7f1353b New Crowdin updates (#316)
* New translations integration.mdx (Chinese Simplified)

* New translations merge.mdx (Chinese Simplified)

* New translations elevation.mdx (Chinese Simplified)

* New translations en.json (Chinese Simplified)

* New translations file.mdx (Ukrainian)

* New translations en.json (German)

* New translations integration.mdx (German)

* New translations map-controls.mdx (German)

* New translations file.mdx (German)

* New translations merge.mdx (German)

* New translations elevation.mdx (German)
2026-04-04 23:14:44 +02:00
vcoppe
30de3c6db5 update motorcycle profile 2026-04-04 20:10:53 +02:00
vcoppe
04a1bf6a55 Merge branch 'dev' 2026-04-03 08:47:57 +02:00
vcoppe
8aafa26238 fix for safari 2026-04-03 08:47:40 +02:00
vcoppe
0989371874 Merge branch 'dev' 2026-04-03 08:06:19 +02:00
vcoppe
5768391305 set default value for new layers 2026-04-03 08:06:09 +02:00
vcoppe
3dcd6e52d3 Merge branch 'dev' 2026-04-02 22:35:46 +02:00
vcoppe
1d204bacf2 fix threshold 2026-04-02 22:35:36 +02:00
vcoppe
0f06d0d461 update 2026-04-02 22:23:34 +02:00
vcoppe
db33310a10 revert some changes 2026-04-02 22:20:47 +02:00
vcoppe
4d1d5d48c0 avoid layout shift 2026-04-02 20:23:27 +02:00
vcoppe
5a6321535e New translations settings.mdx (Greek) (#315) 2026-04-02 18:58:58 +02:00
vcoppe
e077de9f48 remove obsolete files 2026-04-02 18:47:59 +02:00
vcoppe
7586f03998 New Crowdin updates (#307)
* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Thai)

* New translations en.json (Latvian)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Serbian (Latin))

* New translations funding.mdx (Dutch)

* New translations en.json (Dutch)

* New translations en.json (Spanish)

* New translations en.json (Basque)

* New translations en.json (Dutch)

* New translations en.json (Catalan)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Belarusian)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Thai)

* New translations en.json (Latvian)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Serbian (Latin))

* New translations files-and-stats.mdx (Romanian)

* New translations files-and-stats.mdx (French)

* New translations files-and-stats.mdx (Spanish)

* New translations files-and-stats.mdx (Belarusian)

* New translations files-and-stats.mdx (Catalan)

* New translations files-and-stats.mdx (Czech)

* New translations files-and-stats.mdx (Danish)

* New translations files-and-stats.mdx (German)

* New translations files-and-stats.mdx (Greek)

* New translations files-and-stats.mdx (Basque)

* New translations files-and-stats.mdx (Finnish)

* New translations files-and-stats.mdx (Hebrew)

* New translations files-and-stats.mdx (Hungarian)

* New translations files-and-stats.mdx (Italian)

* New translations files-and-stats.mdx (Korean)

* New translations files-and-stats.mdx (Lithuanian)

* New translations files-and-stats.mdx (Dutch)

* New translations files-and-stats.mdx (Norwegian)

* New translations files-and-stats.mdx (Polish)

* New translations files-and-stats.mdx (Portuguese)

* New translations files-and-stats.mdx (Russian)

* New translations files-and-stats.mdx (Swedish)

* New translations files-and-stats.mdx (Turkish)

* New translations files-and-stats.mdx (Ukrainian)

* New translations files-and-stats.mdx (Chinese Simplified)

* New translations files-and-stats.mdx (Vietnamese)

* New translations files-and-stats.mdx (Portuguese, Brazilian)

* New translations files-and-stats.mdx (Indonesian)

* New translations files-and-stats.mdx (Thai)

* New translations files-and-stats.mdx (Latvian)

* New translations files-and-stats.mdx (Chinese Traditional, Hong Kong)

* New translations files-and-stats.mdx (Serbian (Latin))

* New translations integration.mdx (Romanian)

* New translations integration.mdx (French)

* New translations integration.mdx (Spanish)

* New translations integration.mdx (Belarusian)

* New translations integration.mdx (Catalan)

* New translations integration.mdx (Czech)

* New translations integration.mdx (Danish)

* New translations integration.mdx (German)

* New translations integration.mdx (Greek)

* New translations integration.mdx (Basque)

* New translations integration.mdx (Finnish)

* New translations integration.mdx (Hebrew)

* New translations integration.mdx (Hungarian)

* New translations integration.mdx (Italian)

* New translations integration.mdx (Korean)

* New translations integration.mdx (Lithuanian)

* New translations integration.mdx (Dutch)

* New translations integration.mdx (Norwegian)

* New translations integration.mdx (Polish)

* New translations integration.mdx (Portuguese)

* New translations integration.mdx (Russian)

* New translations integration.mdx (Swedish)

* New translations integration.mdx (Turkish)

* New translations integration.mdx (Ukrainian)

* New translations integration.mdx (Chinese Simplified)

* New translations integration.mdx (Vietnamese)

* New translations integration.mdx (Portuguese, Brazilian)

* New translations integration.mdx (Indonesian)

* New translations integration.mdx (Thai)

* New translations integration.mdx (Latvian)

* New translations integration.mdx (Chinese Traditional, Hong Kong)

* New translations integration.mdx (Serbian (Latin))

* New translations merge.mdx (Romanian)

* New translations merge.mdx (French)

* New translations merge.mdx (Spanish)

* New translations merge.mdx (Belarusian)

* New translations merge.mdx (Catalan)

* New translations merge.mdx (Czech)

* New translations merge.mdx (Danish)

* New translations merge.mdx (German)

* New translations merge.mdx (Greek)

* New translations merge.mdx (Basque)

* New translations merge.mdx (Finnish)

* New translations merge.mdx (Hebrew)

* New translations merge.mdx (Hungarian)

* New translations merge.mdx (Italian)

* New translations merge.mdx (Korean)

* New translations merge.mdx (Lithuanian)

* New translations merge.mdx (Dutch)

* New translations merge.mdx (Norwegian)

* New translations merge.mdx (Polish)

* New translations merge.mdx (Portuguese)

* New translations merge.mdx (Russian)

* New translations merge.mdx (Swedish)

* New translations merge.mdx (Turkish)

* New translations merge.mdx (Ukrainian)

* New translations merge.mdx (Chinese Simplified)

* New translations merge.mdx (Vietnamese)

* New translations merge.mdx (Portuguese, Brazilian)

* New translations merge.mdx (Indonesian)

* New translations merge.mdx (Thai)

* New translations merge.mdx (Latvian)

* New translations merge.mdx (Chinese Traditional, Hong Kong)

* New translations merge.mdx (Serbian (Latin))

* New translations map-controls.mdx (Romanian)

* New translations map-controls.mdx (French)

* New translations map-controls.mdx (Spanish)

* New translations map-controls.mdx (Belarusian)

* New translations map-controls.mdx (Catalan)

* New translations map-controls.mdx (Czech)

* New translations map-controls.mdx (Danish)

* New translations map-controls.mdx (German)

* New translations map-controls.mdx (Greek)

* New translations map-controls.mdx (Basque)

* New translations map-controls.mdx (Finnish)

* New translations map-controls.mdx (Hebrew)

* New translations map-controls.mdx (Hungarian)

* New translations map-controls.mdx (Italian)

* New translations map-controls.mdx (Korean)

* New translations map-controls.mdx (Lithuanian)

* New translations map-controls.mdx (Dutch)

* New translations map-controls.mdx (Norwegian)

* New translations map-controls.mdx (Polish)

* New translations map-controls.mdx (Portuguese)

* New translations map-controls.mdx (Russian)

* New translations map-controls.mdx (Swedish)

* New translations map-controls.mdx (Turkish)

* New translations map-controls.mdx (Ukrainian)

* New translations map-controls.mdx (Chinese Simplified)

* New translations map-controls.mdx (Vietnamese)

* New translations map-controls.mdx (Portuguese, Brazilian)

* New translations map-controls.mdx (Indonesian)

* New translations map-controls.mdx (Thai)

* New translations map-controls.mdx (Latvian)

* New translations map-controls.mdx (Chinese Traditional, Hong Kong)

* New translations map-controls.mdx (Serbian (Latin))

* New translations en.json (French)

* New translations integration.mdx (French)

* New translations map-controls.mdx (French)

* New translations merge.mdx (French)

* New translations elevation.mdx (French)

* New translations en.json (Basque)

* New translations en.json (Dutch)

* New translations en.json (Catalan)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Belarusian)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Thai)

* New translations en.json (Latvian)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Serbian (Latin))

* New translations integration.mdx (Dutch)

* New translations map-controls.mdx (Spanish)

* New translations map-controls.mdx (Dutch)

* New translations merge.mdx (Dutch)

* New translations en.json (Basque)

* New translations en.json (Dutch)

* New translations en.json (Catalan)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Belarusian)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Thai)

* New translations en.json (Latvian)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Serbian (Latin))

* New translations en.json (Dutch)

* New translations en.json (French)

* New translations en.json (Spanish)
2026-04-02 18:43:41 +02:00
vcoppe
af8c22dcda redirect to app from image 2026-04-02 18:41:49 +02:00
vcoppe
3dce5dc617 improve filtering to always show layers in the same order 2026-04-01 09:01:01 +02:00
vcoppe
84b90e1026 remove unused import 2026-04-01 08:36:33 +02:00
vcoppe
d507586eed fix small shift 2026-04-01 08:33:17 +02:00
vcoppe
57afaedf83 rephrasing 2026-03-29 23:22:36 +02:00
vcoppe
48063b9066 allow line breaks in buttons 2026-03-29 23:06:59 +02:00
vcoppe
452d356599 rephrase 2026-03-29 22:56:48 +02:00
vcoppe
25eda8041e slight rephrasing 2026-03-29 22:38:31 +02:00
vcoppe
ae4d5356eb fix color 2026-03-29 22:31:57 +02:00
vcoppe
3343bb906e format for crowdin 2026-03-29 20:34:52 +02:00
vcoppe
7b17900160 simplify styling 2026-03-29 20:21:52 +02:00
vcoppe
d5f1fe1c7b finish homepage 2026-03-29 20:04:38 +02:00
vcoppe
553f73f992 change break point for centering 2026-03-29 15:14:21 +02:00
vcoppe
c8cedf2e2c simplify illustration 2026-03-29 14:06:59 +02:00
vcoppe
a751817847 max size for docs 2026-03-28 19:41:44 +01:00
vcoppe
d1ef12db8d work in progress 2026-03-28 19:31:52 +01:00
vcoppe
43d73edf29 small fix 2026-03-28 17:12:43 +01:00
vcoppe
5a0b8c376c small ui changes 2026-03-28 13:03:25 +01:00
vcoppe
9743fd460e update embedding instructions 2026-03-28 12:09:31 +01:00
vcoppe
f70f92a176 change note type 2026-03-28 11:42:11 +01:00
vcoppe
1a4175446c add missing instructions 2026-03-28 11:40:27 +01:00
vcoppe
ed6dfab4c1 fix embedding spacing 2026-03-28 11:32:25 +01:00
vcoppe
6a6e1105c0 update images 2026-03-28 11:32:05 +01:00
vcoppe
1677fe254b move theme button and search bar 2026-03-28 08:41:30 +01:00
vcoppe
02efe708c2 update maplibre 2026-03-27 21:47:17 +01:00
vcoppe
7dc834f506 small ui fixes 2026-03-27 21:32:33 +01:00
vcoppe
57c4958ff2 refresh routing controls on style load 2026-03-27 21:23:51 +01:00
vcoppe
03e59a8cce change default basemap 2026-03-27 19:43:01 +01:00
vcoppe
f3d18f09a0 refresh markers on style load 2026-03-27 19:21:25 +01:00
vcoppe
c1dbd984e6 fix validator 2026-03-27 19:07:48 +01:00
vcoppe
e4f227221d small detail 2026-03-27 18:49:32 +01:00
vcoppe
34139974aa change 3D shortcut 2026-03-27 18:49:20 +01:00
vcoppe
408b2422e6 add missing value 2026-03-27 18:35:25 +01:00
vcoppe
b59cb9e200 Merge branch 'maplibre' into dev 2026-03-27 18:31:23 +01:00
vcoppe
bd5cb65d0f switch ko-fi to open collective 2026-03-25 21:53:10 +01:00
vcoppe
4cfe487af0 fix typo 2026-03-18 18:35:33 +01:00
vcoppe
4da2e39e32 Merge branch 'graphhopper' into dev
migrate to graphhopper
2026-03-18 18:28:05 +01:00
vcoppe
5ff11a32c9 update readme 2026-03-15 17:00:03 +01:00
vcoppe
01a7ec916e remove console log 2026-03-07 15:59:08 +01:00
vcoppe
dd94a7d613 catch graphhopper exceptions 2026-03-07 15:57:58 +01:00
vcoppe
089b88c62d update graphhopper url 2026-03-07 15:30:22 +01:00
vcoppe
c9ca75e2e8 small ui improvements 2026-02-17 22:24:14 +01:00
vcoppe
091f6a3ed0 adapt routing control size to canvas width 2026-02-17 21:12:04 +01:00
vcoppe
d6c9fb1025 split routing controls in zoom-specific layers to improve performance 2026-02-14 15:05:23 +01:00
vcoppe
88abd72a41 layer instead of markers for routing controls 2026-02-14 14:35:35 +01:00
vcoppe
1137e851ce remove company support section until clarified 2026-02-11 18:31:08 +01:00
vcoppe
b8c1500aad fix layer filtering in event manager 2026-02-02 21:50:01 +01:00
vcoppe
bfd0d90abc validate settings 2026-02-01 18:45:40 +01:00
vcoppe
dba01e1826 finish renaming 2026-02-01 18:06:16 +01:00
vcoppe
2189c76edd renaming 2026-02-01 17:46:24 +01:00
vcoppe
6f8c9d66db use map layers for start/end/hover markers 2026-02-01 17:18:17 +01:00
vcoppe
9408ce10c7 check that map contains the layer 2026-02-01 16:26:17 +01:00
vcoppe
9895c3c304 further improve event listener performance 2026-02-01 15:57:18 +01:00
vcoppe
0ab3b77db8 centralized map layer event listener for better performance 2026-01-31 12:57:08 +01:00
vcoppe
d13e7e7a0a update package-lock 2026-01-30 23:13:02 +01:00
vcoppe
e96b544a75 switch to maplibre, but laggy 2026-01-30 21:01:24 +01:00
vcoppe
a01ca79a82 finer-grained road access 2026-01-18 15:23:39 +01:00
vcoppe
c91baf7c83 switch gravel to graphhopper 2026-01-17 11:58:47 +01:00
vcoppe
5062de8ddf Merge branch 'dev' into graphhopper 2026-01-17 11:42:30 +01:00
vcoppe
9ca46b9d35 small fix 2025-12-24 17:21:26 +01:00
vcoppe
7c2e24bbc4 draft support for graphhopper 2025-12-23 16:49:47 +01:00
557 changed files with 9768 additions and 9380 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1 +1 @@
ko_fi: gpxstudio open_collective: gpxstudio

View File

@@ -31,7 +31,7 @@ jobs:
- name: Create env file - name: Create env file
run: | run: |
touch website/.env touch website/.env
echo PUBLIC_MAPBOX_TOKEN=${{ secrets.PUBLIC_MAPBOX_TOKEN }} >> website/.env echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env
cat website/.env cat website/.env
- name: Build website - name: Build website

View File

@@ -5,7 +5,7 @@
[**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files. [**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files.
![gpx.studio screenshot](website/src/lib/assets/img/docs/getting-started/interface.png) ![gpx.studio screenshot](website/src/lib/assets/img/docs/getting-started/interface.webp)
This repository contains the source code of the website. This repository contains the source code of the website.
@@ -27,8 +27,8 @@ Any help is greatly appreciated!
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.
You will need [Node.js](https://nodejs.org/) to build and run these two parts. You will need [Node.js](https://nodejs.org/) to build and run these two parts.
@@ -42,11 +42,11 @@ npm run build
### Running the website ### Running the website
To be able to load the map, you will need to create your own <a href="https://account.mapbox.com/auth/signup" target="_blank">Mapbox access token</a> and store it in a `.env` file in the `website` directory. To be able to load the map, you will need to create your own <a href="https://cloud.maptiler.com/auth/widget?next=https://cloud.maptiler.com/maps/" target="_blank">MapTiler key</a> and store it in a `.env` file in the `website` directory.
```bash ```bash
cd website cd website
echo PUBLIC_MAPBOX_TOKEN={YOUR_MAPBOX_TOKEN} >> .env echo PUBLIC_MAPTILER_KEY={YOUR_MAPTILER_KEY} >> .env
npm install npm install
npm run dev npm run dev
``` ```
@@ -55,25 +55,25 @@ npm run dev
This project has been made possible thanks to the following open source projects: 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
- 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/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:
- [immer](https://github.com/immerjs/immer) — complex state management - [immer](https://github.com/immerjs/immer) — complex state management
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper - [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing - [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree - [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
- Mapping: - Mapping:
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps - [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive map rendering
- [brouter](https://github.com/abrensch/brouter) — routing engine - [GraphHopper](https://github.com/graphhopper/graphhopper) — routing engine
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter - [OpenStreetMap](https://www.openstreetmap.org) — map data used by most of the map layers, and by the routing engine
- Search: - Search:
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation - [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
## License ## License

View File

@@ -1398,10 +1398,7 @@ export class TrackPoint {
: undefined; : undefined;
} }
setExtensions(extensions: Record<string, string>) { setExtension(key: string, value: string) {
if (Object.keys(extensions).length === 0) {
return;
}
if (!this.extensions) { if (!this.extensions) {
this.extensions = {}; this.extensions = {};
} }
@@ -1411,8 +1408,12 @@ export class TrackPoint {
if (!this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']) { if (!this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']) {
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] = {}; this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] = {};
} }
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value;
}
setExtensions(extensions: Record<string, string>) {
Object.entries(extensions).forEach(([key, value]) => { Object.entries(extensions).forEach(([key, value]) => {
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value; this.setExtension(key, value);
}); });
} }

View File

@@ -1 +1 @@
PUBLIC_MAPBOX_TOKEN=YOUR_MAPBOX_TOKEN PUBLIC_MAPTILER_KEY=YOUR_MAPTILER_KEY

View File

@@ -1,17 +1,20 @@
{ {
"$schema": "https://shadcn-svelte.com/schema.json", "$schema": "https://shadcn-svelte.com/schema.json",
"style": "default", "tailwind": {
"tailwind": { "css": "src/app.css",
"css": "src/app.css", "baseColor": "neutral"
"baseColor": "slate" },
}, "aliases": {
"aliases": { "components": "$lib/components",
"components": "$lib/components", "utils": "$lib/utils",
"utils": "$lib/utils", "ui": "$lib/components/ui",
"ui": "$lib/components/ui", "hooks": "$lib/hooks",
"hooks": "$lib/hooks", "lib": "$lib"
"lib": "$lib" },
}, "typescript": true,
"typescript": true, "registry": "https://shadcn-svelte.com/registry",
"registry": "https://shadcn-svelte.com/registry" "style": "nova",
"iconLibrary": "lucide",
"menuColor": "default",
"menuAccent": "subtle"
} }

8551
website/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,9 @@
"format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore" "format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore"
}, },
"devDependencies": { "devDependencies": {
"@lucide/svelte": "^0.544.0", "@fontsource-variable/inter": "^5.2.8",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.7.0",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.6.0", "@sveltejs/enhanced-img": "^0.6.0",
"@sveltejs/kit": "^2.21.2", "@sveltejs/kit": "^2.21.2",
@@ -23,15 +25,15 @@
"@types/eslint": "^9.6.1", "@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__sphericalmercator": "^1.2.3",
"@types/mapbox__tilebelt": "^1.0.4", "@types/mapbox__tilebelt": "^1.0.4",
"@types/mapbox-gl": "^3.4.1",
"@types/node": "^22.15.30", "@types/node": "^22.15.30",
"@types/png.js": "^0.2.3",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.16.0",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^8.33.1", "@typescript-eslint/eslint-plugin": "^8.33.1",
"@typescript-eslint/parser": "^8.33.1", "@typescript-eslint/parser": "^8.33.1",
"bits-ui": "^2.14.4", "bits-ui": "^2.17.2",
"clsx": "^2.1.1",
"eslint": "^9.28.0", "eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.9.1", "eslint-plugin-svelte": "^3.9.1",
@@ -44,41 +46,37 @@
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"shadcn-svelte": "^1.2.7",
"svelte": "^5.33.18", "svelte": "^5.33.18",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"svelte-dnd-action": "^0.9.65", "svelte-dnd-action": "^0.9.65",
"svelte-sonner": "^1.0.5", "svelte-sonner": "^1.1.0",
"tailwind-variants": "^3.1.1", "tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"tsx": "^4.19.1", "tsx": "^4.19.1",
"tw-animate-css": "^1.3.4", "tw-animate-css": "^1.4.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vaul-svelte": "^1.0.0-next.7", "vaul-svelte": "^1.0.0-next.7",
"vite": "^6.3.5", "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.9.0",
"@internationalized/date": "^3.8.2",
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
"@mapbox/sphericalmercator": "^2.0.1", "@mapbox/sphericalmercator": "^2.0.1",
"@mapbox/tilebelt": "^2.0.2", "@mapbox/tilebelt": "^2.0.2",
"@types/mapbox__sphericalmercator": "^1.2.3", "@maplibre/maplibre-gl-geocoder": "^1.9.4",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-plugin-zoom": "^2.2.0", "chartjs-plugin-zoom": "^2.2.0",
"clsx": "^2.1.1",
"dexie": "^4.0.11", "dexie": "^4.0.11",
"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.17.0",
"mapillary-js": "^4.1.2", "mapillary-js": "^4.1.2",
"png.js": "^0.2.1", "maplibre-gl": "^5.21.1",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6"
"tailwind-merge": "^3.3.0"
} }
} }

View File

@@ -1,76 +1,93 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
@import "shadcn-svelte/tailwind.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: hsl(0 0% 100%) /* <- Wrap in HSL */; --background: oklch(1 0 0);
--foreground: hsl(240 10% 3.9%); --foreground: oklch(0.145 0 0);
--muted: hsl(240 4.8% 95.9%); --muted: oklch(0.97 0 0);
--muted-foreground: hsl(240 3.8% 46.1%); --muted-foreground: oklch(0.556 0 0);
--popover: hsl(0 0% 100%); --popover: oklch(1 0 0);
--popover-foreground: hsl(240 10% 3.9%); --popover-foreground: oklch(0.145 0 0);
--card: hsl(0 0% 100%); --card: oklch(1 0 0);
--card-foreground: hsl(240 10% 3.9%); --card-foreground: oklch(0.145 0 0);
--border: hsl(240 5.9% 90%); --border: oklch(0.922 0 0);
--input: hsl(240 5.9% 90%); --input: oklch(0.922 0 0);
--primary: hsl(240 5.9% 10%); --primary: oklch(0.205 0 0);
--primary-foreground: hsl(0 0% 98%); --primary-foreground: oklch(0.985 0 0);
--secondary: hsl(240 4.8% 95.9%); --secondary: oklch(0.97 0 0);
--secondary-foreground: hsl(240 5.9% 10%); --secondary-foreground: oklch(0.205 0 0);
--accent: hsl(240 4.8% 95.9%); --accent: oklch(0.97 0 0);
--accent-foreground: hsl(240 5.9% 10%); --accent-foreground: oklch(0.205 0 0);
--destructive: hsl(0 72.2% 50.6%); --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: hsl(0 0% 98%); --destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 10% 3.9%); --ring: oklch(0.708 0 0);
--sidebar: hsl(0 0% 98%); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: hsl(240 5.3% 26.1%); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: hsl(240 5.9% 10%); --sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: hsl(0 0% 98%); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: hsl(240 4.8% 95.9%); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: hsl(240 5.9% 10%); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: hsl(220 13% 91%); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: hsl(217.2 91.2% 59.8%); --sidebar-ring: oklch(0.708 0 0);
--support: rgb(220 15 130); --support: rgb(220 15 130);
--link: rgb(0 110 180); --link: rgb(0 110 180);
--selection: hsl(240 4.8% 93%); --selection: hsl(240 4.8% 93%);
--radius: 0.5rem; --radius: 0.5rem;
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
} }
.dark { .dark {
--background: hsl(240 10% 3.9%); --background: oklch(0.145 0 0);
--foreground: hsl(0 0% 98%); --foreground: oklch(0.985 0 0);
--muted: hsl(240 3.7% 15.9%); --muted: oklch(0.269 0 0);
--muted-foreground: hsl(240 5% 64.9%); --muted-foreground: oklch(0.708 0 0);
--popover: hsl(240 10% 3.9%); --popover: oklch(0.205 0 0);
--popover-foreground: hsl(0 0% 98%); --popover-foreground: oklch(0.985 0 0);
--card: hsl(240 10% 3.9%); --card: oklch(0.205 0 0);
--card-foreground: hsl(0 0% 98%); --card-foreground: oklch(0.985 0 0);
--border: hsl(240 3.7% 15.9%); --border: oklch(1 0 0 / 10%);
--input: hsl(240 3.7% 15.9%); --input: oklch(1 0 0 / 15%);
--primary: hsl(0 0% 98%); --primary: oklch(0.922 0 0);
--primary-foreground: hsl(240 5.9% 10%); --primary-foreground: oklch(0.205 0 0);
--secondary: hsl(240 3.7% 15.9%); --secondary: oklch(0.269 0 0);
--secondary-foreground: hsl(0 0% 98%); --secondary-foreground: oklch(0.985 0 0);
--accent: hsl(240 3.7% 15.9%); --accent: oklch(0.269 0 0);
--accent-foreground: hsl(0 0% 98%); --accent-foreground: oklch(0.985 0 0);
--destructive: hsl(0 62.8% 30.6%); --destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: hsl(0 0% 98%); --destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 4.9% 83.9%); --ring: oklch(0.556 0 0);
--sidebar: hsl(240 5.9% 10%); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: hsl(240 4.8% 95.9%); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: hsl(224.3 76.3% 48%); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: hsl(0 0% 100%); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: hsl(240 3.7% 15.9%); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: hsl(240 3.7% 15.9%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: hsl(217.2 91.2% 59.8%); --sidebar-ring: oklch(0.556 0 0);
--support: rgb(255 110 190); --support: rgb(255 110 190);
--link: rgb(80 190 255); --link: rgb(80 190 255);
--selection: hsl(240 3.7% 22%); --selection: hsl(240 3.7% 22%);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
} }
@theme inline { @theme inline {
@@ -113,14 +130,35 @@
--color-link: var(--link); --color-link: var(--link);
--breakpoint-xs: 540px; --breakpoint-xs: 540px;
--font-sans: 'Inter Variable', sans-serif;
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
} }
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
html {
@apply font-sans;
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

After

Width:  |  Height:  |  Size: 348 KiB

View File

@@ -22,15 +22,41 @@ import {
Binoculars, Binoculars,
Toilet, Toilet,
} from 'lucide-static'; } from 'lucide-static';
import { type RasterDEMSourceSpecification, type StyleSpecification } from 'mapbox-gl'; import { type RasterDEMSourceSpecification, type StyleSpecification } from 'maplibre-gl';
import ignFrTopo from './custom/ign-fr-topo.json'; import ignFrTopo from './custom/ign-fr-topo.json';
import ignFrPlan from './custom/ign-fr-plan.json'; import ignFrPlan from './custom/ign-fr-plan.json';
import ignFrSatellite from './custom/ign-fr-satellite.json'; import ignFrSatellite from './custom/ign-fr-satellite.json';
import bikerouterGravel from './custom/bikerouter-gravel.json'; import bikerouterGravel from './custom/bikerouter-gravel.json';
export const maptilerKeyPlaceHolder = 'MAPTILER_KEY';
export const basemaps: { [key: string]: string | StyleSpecification } = { export const basemaps: { [key: string]: string | StyleSpecification } = {
mapboxOutdoors: 'mapbox://styles/mapbox/outdoors-v12', maptilerStreets: `https://api.maptiler.com/maps/streets-v4/style.json?key=${maptilerKeyPlaceHolder}`,
mapboxSatellite: 'mapbox://styles/mapbox/satellite-streets-v12', maptilerTopo: `https://api.maptiler.com/maps/topo-v4/style.json?key=${maptilerKeyPlaceHolder}`,
maptilerOutdoors: `https://api.maptiler.com/maps/outdoor-v4/style.json?key=${maptilerKeyPlaceHolder}`,
maptilerSatellite: `https://api.maptiler.com/maps/hybrid-v4/style.json?key=${maptilerKeyPlaceHolder}`,
esriSatellite: {
version: 8,
sources: {
esriSatellite: {
type: 'raster',
tiles: [
'https://services.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/WMTS/tile/1.0.0/World_Imagery/default/default028mm/{z}/{y}/{x}.jpg',
],
tileSize: 256,
maxzoom: 19,
attribution:
'© <a href="https://www.esri.com/" target="_blank">Esri</a>, Vantor, Earthstar Geographics, and the GIS User Community',
},
},
layers: [
{
id: 'esriSatellite',
type: 'raster',
source: 'esriSatellite',
},
],
},
openStreetMap: { openStreetMap: {
version: 8, version: 8,
sources: { sources: {
@@ -773,8 +799,11 @@ export type LayerTreeType = { [key: string]: LayerTreeType | boolean };
export const basemapTree: LayerTreeType = { export const basemapTree: LayerTreeType = {
basemaps: { basemaps: {
world: { world: {
mapboxOutdoors: true, maptilerStreets: true,
mapboxSatellite: true, maptilerTopo: true,
maptilerOutdoors: true,
maptilerSatellite: true,
esriSatellite: true,
openStreetMap: true, openStreetMap: true,
openTopoMap: true, openTopoMap: true,
openHikingMap: true, openHikingMap: true,
@@ -907,7 +936,7 @@ export const overpassTree: LayerTreeType = {
}; };
// Default basemap used // Default basemap used
export const defaultBasemap = 'mapboxOutdoors'; export const defaultBasemap = 'maptilerStreets';
// Default overlays used (none) // Default overlays used (none)
export const defaultOverlays: LayerTreeType = { export const defaultOverlays: LayerTreeType = {
@@ -996,8 +1025,11 @@ export const defaultOverpassQueries: LayerTreeType = {
export const defaultBasemapTree: LayerTreeType = { export const defaultBasemapTree: LayerTreeType = {
basemaps: { basemaps: {
world: { world: {
mapboxOutdoors: true, maptilerStreets: true,
mapboxSatellite: true, maptilerTopo: true,
maptilerOutdoors: true,
maptilerSatellite: true,
esriSatellite: false,
openStreetMap: true, openStreetMap: true,
openTopoMap: true, openTopoMap: true,
openHikingMap: true, openHikingMap: true,
@@ -1136,7 +1168,7 @@ export type CustomLayer = {
maxZoom: number; maxZoom: number;
layerType: 'basemap' | 'overlay'; layerType: 'basemap' | 'overlay';
resourceType: 'raster' | 'vector'; resourceType: 'raster' | 'vector';
value: string | {}; value: string | maplibregl.StyleSpecification;
}; };
type OverpassQueryData = { type OverpassQueryData = {
@@ -1455,11 +1487,9 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
}; };
export const terrainSources: { [key: string]: RasterDEMSourceSpecification } = { export const terrainSources: { [key: string]: RasterDEMSourceSpecification } = {
'mapbox-dem': { 'maptiler-dem': {
type: 'raster-dem', type: 'raster-dem',
url: 'mapbox://mapbox.mapbox-terrain-dem-v1', url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${maptilerKeyPlaceHolder}`,
tileSize: 512,
maxzoom: 14,
}, },
mapterhorn: { mapterhorn: {
type: 'raster-dem', type: 'raster-dem',
@@ -1467,4 +1497,4 @@ export const terrainSources: { [key: string]: RasterDEMSourceSpecification } = {
}, },
}; };
export const defaultTerrainSource = 'mapbox-dem'; export const defaultTerrainSource = 'maptiler-dem';

View File

@@ -64,3 +64,9 @@
</svelte:head> </svelte:head>
<div id="docsearch" class={props.class ?? ''}></div> <div id="docsearch" class={props.class ?? ''}></div>
<style>
#docsearch :global(button) {
margin-left: 0px;
}
</style>

View File

@@ -26,7 +26,7 @@
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger> <Tooltip.Trigger>
{#snippet child({ props })} {#snippet child({ props })}
<Button {...props} {variant} class={className} {onclick}> <Button {...props} {variant} class="bg-inherit {className}" {onclick}>
{@render children()} {@render children()}
</Button> </Button>
{/snippet} {/snippet}

View File

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

View File

@@ -12,16 +12,17 @@
const { velocityUnits } = settings; const { velocityUnits } = settings;
let panelHeight: number = $state(0);
let panelWidth: number = $state(0);
let { let {
gpxStatistics, gpxStatistics,
slicedGPXStatistics, slicedGPXStatistics,
orientation, orientation,
panelSize,
}: { }: {
gpxStatistics: Readable<GPXStatisticsGroup>; gpxStatistics: Readable<GPXStatisticsGroup>;
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>; slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
orientation: 'horizontal' | 'vertical'; orientation: 'horizontal' | 'vertical';
panelSize: number;
} = $props(); } = $props();
let statistics = $derived( let statistics = $derived(
@@ -31,59 +32,61 @@
<Card.Root <Card.Root
class="h-full {orientation === 'vertical' class="h-full {orientation === 'vertical'
? 'min-w-40 sm:min-w-44 text-sm sm:text-base' ? 'min-w-40 sm:min-w-44'
: 'w-full'} border-none shadow-none p-0" : 'w-full h-fit my-1'} ring-0 p-0 text-sm sm:text-base bg-transparent"
> >
<Card.Content <Card.Content class="h-full p-0">
class="h-full flex {orientation === 'vertical' <div
? 'flex-col justify-center' bind:clientHeight={panelHeight}
: 'flex-row w-full justify-between'} gap-4 p-0" bind:clientWidth={panelWidth}
> class="flex {orientation === 'vertical'
<Tooltip label={i18n._('quantities.distance')}> ? 'flex-col h-full justify-center'
<span class="flex flex-row items-center"> : 'flex-row w-full justify-evenly'} gap-4"
<Ruler size="16" class="mr-1" /> >
<WithUnits value={statistics.distance.total} type="distance" /> <Tooltip label={i18n._('quantities.distance')}>
</span>
</Tooltip>
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.elevation.gain} type="elevation" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.elevation.loss} type="elevation" />
</span>
</Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
label="{$velocityUnits === 'speed'
? i18n._('quantities.speed')
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Zap size="16" class="mr-1" /> <Ruler size="16" class="mr-1" />
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} /> <WithUnits value={statistics.distance.total} type="distance" />
<span class="mx-1">/</span>
<WithUnits value={statistics.speed.total} type="speed" />
</span> </span>
</Tooltip> </Tooltip>
{/if} <Tooltip label={i18n._('quantities.elevation_gain_loss')}>
{#if panelSize > 160 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Timer size="16" class="mr-1" /> <MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.time.moving} type="time" /> <WithUnits value={statistics.elevation.gain} type="elevation" />
<span class="mx-1">/</span> <MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.time.total} type="time" /> <WithUnits value={statistics.elevation.loss} type="elevation" />
</span> </span>
</Tooltip> </Tooltip>
{/if} {#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)}
<Tooltip
label="{$velocityUnits === 'speed'
? i18n._('quantities.speed')
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center">
<Zap size="16" class="mr-1" />
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
<span class="mx-1">/</span>
<WithUnits value={statistics.speed.total} type="speed" />
</span>
</Tooltip>
{/if}
{#if panelHeight > 150 || (orientation === 'horizontal' && panelWidth > 620)}
<Tooltip
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.time.moving} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.time.total} type="time" />
</span>
</Tooltip>
{/if}
</div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>

View File

@@ -14,12 +14,12 @@
} = $props(); } = $props();
</script> </script>
<div class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {className}"> <div class="text-[13px] bg-secondary rounded border flex flex-row items-center p-2 {className}">
<CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" /> <CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" />
<div> <div>
{@render children()} {@render children()}
{#if link} {#if link}
<a href={link} target="_blank" class="text-sm text-link hover:underline"> <a href={link} target="_blank" class="text-[13px] text-link hover:underline">
{i18n._('menu.more')} {i18n._('menu.more')}
</a> </a>
{/if} {/if}

View File

@@ -5,16 +5,10 @@
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 { i18n } from '$lib/i18n.svelte';
let {
class: className = '',
}: {
class?: string;
} = $props();
</script> </script>
<Select.Root type="single" value={i18n.lang}> <Select.Root type="single" value={i18n.lang}>
<Select.Trigger class="min-w-[180px] {className}" aria-label={i18n._('menu.language')}> <Select.Trigger class="w-[180px] px-2" aria-label={i18n._('menu.language')}>
<Languages size="16" /> <Languages size="16" />
<span class="mr-auto"> <span class="mr-auto">
{languages[i18n.lang]} {languages[i18n.lang]}

View File

@@ -8,7 +8,7 @@
...others ...others
}: { }: {
iconOnly?: boolean; iconOnly?: boolean;
company?: 'gpx.studio' | 'mapbox' | 'github' | 'crowdin' | 'facebook' | 'reddit'; company?: 'gpx.studio' | 'maptiler' | 'github' | 'crowdin' | 'facebook' | 'reddit';
[key: string]: any; [key: string]: any;
} = $props(); } = $props();
</script> </script>
@@ -19,10 +19,10 @@
alt="Logo of gpx.studio." alt="Logo of gpx.studio."
{...others} {...others}
/> />
{:else if company === 'mapbox'} {:else if company === 'maptiler'}
<img <img
src="{base}/mapbox-logo-{mode.current === 'dark' ? 'white' : 'black'}.svg" src="{base}/maptiler-logo{mode.current === 'dark' ? '-dark' : ''}.svg"
alt="Logo of Mapbox." alt="Logo of Maptiler."
{...others} {...others}
/> />
{:else if company === 'github'} {:else if company === 'github'}

View File

@@ -375,7 +375,7 @@
<Menubar.Item inset onclick={() => map.toggle3D()}> <Menubar.Item inset onclick={() => map.toggle3D()}>
<Box size="16" /> <Box size="16" />
{i18n._('menu.toggle_3d')} {i18n._('menu.toggle_3d')}
<Shortcut key="{i18n._('menu.ctrl')} {i18n._('menu.drag')}" /> <Shortcut key={i18n._('menu.right_click_drag')} />
</Menubar.Item> </Menubar.Item>
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
@@ -389,7 +389,7 @@
<Menubar.Content class="border-none"> <Menubar.Content class="border-none">
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<Ruler size="16" class="mr-2" />{i18n._('menu.distance_units')} <Ruler size="16" />{i18n._('menu.distance_units')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={$distanceUnits}> <Menubar.RadioGroup bind:value={$distanceUnits}>
@@ -407,7 +407,7 @@
</Menubar.Sub> </Menubar.Sub>
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<Zap size="16" class="mr-2" />{i18n._('menu.velocity_units')} <Zap size="16" />{i18n._('menu.velocity_units')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={$velocityUnits}> <Menubar.RadioGroup bind:value={$velocityUnits}>
@@ -422,7 +422,7 @@
</Menubar.Sub> </Menubar.Sub>
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<Thermometer size="16" class="mr-2" />{i18n._('menu.temperature_units')} <Thermometer size="16" />{i18n._('menu.temperature_units')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={$temperatureUnits}> <Menubar.RadioGroup bind:value={$temperatureUnits}>
@@ -438,7 +438,7 @@
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<Languages size="16" class="mr-2" /> <Languages size="16" />
{i18n._('menu.language')} {i18n._('menu.language')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
@@ -454,9 +454,9 @@
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
{#if mode.current === 'light' || !mode.current} {#if mode.current === 'light' || !mode.current}
<Sun size="16" class="mr-2" /> <Sun size="16" />
{:else} {:else}
<Moon size="16" class="mr-2" /> <Moon size="16" />
{/if} {/if}
{i18n._('menu.mode')} {i18n._('menu.mode')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
@@ -479,7 +479,7 @@
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<PersonStanding size="16" class="mr-2" /> <PersonStanding size="16" />
{i18n._('menu.street_view_source')} {i18n._('menu.street_view_source')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
@@ -500,12 +500,12 @@
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
</Menubar.Root> </Menubar.Root>
<div class="h-fit flex flex-row items-center ml-1 gap-1"> <div class="h-fit flex flex-row items-center">
<Button <Button
variant="ghost" variant="ghost"
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-md px-3 py-0.5"
aria-label={i18n._('menu.help')} aria-label={i18n._('menu.help')}
> >
<BookOpenText size="18" class="md:hidden" /> <BookOpenText size="18" class="md:hidden" />
@@ -515,9 +515,9 @@
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
href="https://ko-fi.com/gpxstudio" href="https://opencollective.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-md font-bold text-support hover:text-support px-3 py-0.5"
aria-label={i18n._('menu.donate')} aria-label={i18n._('menu.donate')}
> >
<HeartHandshake size="18" class="md:hidden" /> <HeartHandshake size="18" class="md:hidden" />

View File

@@ -12,7 +12,7 @@
</script> </script>
<Button <Button
variant="ghost" variant="outline"
size="icon" size="icon"
class={className} class={className}
onclick={() => { onclick={() => {

View File

@@ -1,22 +1,23 @@
<script lang="ts"> <script lang="ts">
import Logo from '$lib/components/Logo.svelte'; import Logo from '$lib/components/Logo.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
import { BookOpenText, House, Map } from '@lucide/svelte'; import { BookOpenText, House, Map } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
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="sticky top-0 w-full px-12 py-2 bg-background z-50 flex flex-col items-center border-b">
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8"> <div class="w-full max-w-5xl flex flex-row items-center gap-4 sm:gap-8">
<a href={getURLForLanguage(i18n.lang, '/')} class="shrink-0 translate-y-0.5"> <a
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" /> href={getURLForLanguage(i18n.lang, '/')}
<Logo class="h-8 hidden sm:block" width="153" /> class="shrink-0 translate-y-0.25 justify-self-start"
>
<Logo class="h-8 xs:hidden" iconOnly={true} width="26" />
<Logo class="h-8 hidden xs:block" width="153" />
</a> </a>
<Button <Button
variant="link" variant="link"
class="text-base px-0 has-[>svg]:px-0" class="text-base px-0 has-[>svg]:px-0 ml-auto"
href={getURLForLanguage(i18n.lang, '/')} href={getURLForLanguage(i18n.lang, '/')}
> >
<House size="18" /> <House size="18" />
@@ -39,7 +40,5 @@
<BookOpenText size="18" /> <BookOpenText size="18" />
{i18n._('menu.help')} {i18n._('menu.help')}
</Button> </Button>
<AlgoliaDocSearch class="ml-auto" />
<ModeSwitch class="hidden xs:inline-flex" />
</div> </div>
</nav> </nav>

View File

@@ -35,7 +35,7 @@
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="w-full flex flex-row gap-1 {side === 'right' class="w-full flex flex-row gap-1 border-none {side === 'right'
? 'justify-between' ? 'justify-between'
: 'justify-start pl-1'} h-fit {nohover : 'justify-start pl-1'} h-fit {nohover
? 'hover:bg-background' ? 'hover:bg-background'
@@ -62,7 +62,7 @@
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="w-full flex flex-row gap-1 {side === 'right' class="w-full flex flex-row gap-1 border-none {side === 'right'
? 'justify-between' ? 'justify-between'
: 'justify-start pl-1'} h-fit {nohover ? 'hover:bg-background' : ''}" : 'justify-start pl-1'} h-fit {nohover ? 'hover:bg-background' : ''}"
> >

View File

@@ -19,7 +19,7 @@
@apply text-foreground; @apply text-foreground;
@apply text-3xl; @apply text-3xl;
@apply font-semibold; @apply font-semibold;
@apply mb-3 pt-6; @apply mb-3;
} }
:global(.markdown h2) { :global(.markdown h2) {

View File

@@ -12,7 +12,7 @@
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto"> <div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
{#if src === 'getting-started/interface'} {#if src === 'getting-started/interface'}
<enhanced:img <enhanced:img
src="/src/lib/assets/img/docs/getting-started/interface.png" src="/src/lib/assets/img/docs/getting-started/interface.webp"
{alt} {alt}
class="w-full max-w-3xl" class="w-full max-w-3xl"
/> />
@@ -20,13 +20,13 @@
<enhanced:img <enhanced:img
src="/src/lib/assets/img/docs/tools/routing.png" src="/src/lib/assets/img/docs/tools/routing.png"
{alt} {alt}
class="w-full max-w-3xl" class="w-full max-w-lg"
/> />
{:else if src === 'tools/split'} {:else if src === 'tools/split'}
<enhanced:img <enhanced:img
src="/src/lib/assets/img/docs/tools/split.png" src="/src/lib/assets/img/docs/tools/split.png"
{alt} {alt}
class="w-full max-w-3xl" class="w-full max-w-lg"
/> />
{/if} {/if}
</div> </div>

View File

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

View File

@@ -18,7 +18,7 @@
Construction, Construction,
} from '@lucide/svelte'; } from '@lucide/svelte';
import type { Readable, Writable } from 'svelte/store'; import type { Readable, Writable } from 'svelte/store';
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile'; import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
@@ -28,12 +28,14 @@
let { let {
gpxStatistics, gpxStatistics,
slicedGPXStatistics, slicedGPXStatistics,
hoveredPoint,
additionalDatasets, additionalDatasets,
elevationFill, elevationFill,
showControls = true, showControls = true,
}: { }: {
gpxStatistics: Readable<GPXStatisticsGroup>; gpxStatistics: Readable<GPXStatisticsGroup>;
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>; slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
hoveredPoint: Writable<Coordinates | null>;
additionalDatasets: Writable<string[]>; additionalDatasets: Writable<string[]>;
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>; elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
showControls?: boolean; showControls?: boolean;
@@ -47,6 +49,7 @@
elevationProfile = new ElevationProfile( elevationProfile = new ElevationProfile(
gpxStatistics, gpxStatistics,
slicedGPXStatistics, slicedGPXStatistics,
hoveredPoint,
additionalDatasets, additionalDatasets,
elevationFill, elevationFill,
canvas, canvas,
@@ -61,37 +64,35 @@
}); });
</script> </script>
<div class="h-full grow min-w-0 relative py-2"> <div class="h-full grow min-w-0 min-h-0 relative">
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas> <canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas>
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas> <canvas bind:this={canvas} class="w-full h-full absolute"></canvas>
{#if showControls} {#if showControls}
<div class="absolute bottom-10 right-1.5"> <div class="absolute bottom-9 right-2.5">
<Popover.Root> <Popover.Root>
<Popover.Trigger> <Popover.Trigger>
<ButtonWithTooltip <ButtonWithTooltip
label={i18n._('chart.settings')} label={i18n._('chart.settings')}
variant="outline" variant="outline"
side="left" side="left"
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 bg-background"
> >
<ChartNoAxesColumn size="18" /> <ChartNoAxesColumn size="18" />
</ButtonWithTooltip> </ButtonWithTooltip>
</Popover.Trigger> </Popover.Trigger>
<Popover.Content <Popover.Content
class="w-fit p-0 flex flex-col" class="w-fit p-0 flex flex-col gap-0 overflow-hidden"
side="top" side="top"
align="end" align="end"
sideOffset={-32} sideOffset={-32}
> >
<ToggleGroup.Root <ToggleGroup.Root
class="flex flex-col items-start gap-0 p-1 w-full border-none" class="flex flex-col w-full border-none"
type="single" type="single"
size="sm"
bind:value={$elevationFill} bind:value={$elevationFill}
> >
<ToggleGroup.Item <ToggleGroup.Item value="slope" class="w-full flex flex-row justify-start">
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="slope"
>
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'slope'} {#if $elevationFill === 'slope'}
<Circle class="size-1.5 fill-current text-current" /> <Circle class="size-1.5 fill-current text-current" />
@@ -101,9 +102,8 @@
{i18n._('quantities.slope')} {i18n._('quantities.slope')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="surface" value="surface"
variant="outline" class="w-full flex flex-row justify-start"
> >
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'surface'} {#if $elevationFill === 'surface'}
@@ -114,9 +114,8 @@
{i18n._('quantities.surface')} {i18n._('quantities.surface')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="highway" value="highway"
variant="outline" class="w-full flex flex-row justify-start"
> >
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'highway'} {#if $elevationFill === 'highway'}
@@ -129,14 +128,12 @@
</ToggleGroup.Root> </ToggleGroup.Root>
<Separator /> <Separator />
<ToggleGroup.Root <ToggleGroup.Root
class="flex flex-col items-start gap-0 p-1" class="flex flex-col gap-0"
type="multiple" type="multiple"
size="sm"
bind:value={$additionalDatasets} bind:value={$additionalDatasets}
> >
<ToggleGroup.Item <ToggleGroup.Item value="speed" class="w-full flex flex-row justify-start">
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="speed"
>
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('speed')} {#if $additionalDatasets.includes('speed')}
<Check size="14" /> <Check size="14" />
@@ -147,10 +144,7 @@
? i18n._('quantities.speed') ? i18n._('quantities.speed')
: i18n._('quantities.pace')} : i18n._('quantities.pace')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item value="hr" class="w-full flex flex-row justify-start">
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="hr"
>
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('hr')} {#if $additionalDatasets.includes('hr')}
<Check size="14" /> <Check size="14" />
@@ -159,10 +153,7 @@
<HeartPulse size="15" /> <HeartPulse size="15" />
{i18n._('quantities.heartrate')} {i18n._('quantities.heartrate')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item value="cad" class="w-full flex flex-row justify-start">
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="cad"
>
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('cad')} {#if $additionalDatasets.includes('cad')}
<Check size="14" /> <Check size="14" />
@@ -171,10 +162,7 @@
<Orbit size="15" /> <Orbit size="15" />
{i18n._('quantities.cadence')} {i18n._('quantities.cadence')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item value="atemp" class="w-full flex flex-row justify-start">
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="atemp"
>
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('atemp')} {#if $additionalDatasets.includes('atemp')}
<Check size="14" /> <Check size="14" />
@@ -183,10 +171,7 @@
<Thermometer size="15" /> <Thermometer size="15" />
{i18n._('quantities.temperature')} {i18n._('quantities.temperature')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item value="power" class="w-full flex flex-row justify-start">
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="power"
>
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('power')} {#if $additionalDatasets.includes('power')}
<Check size="14" /> <Check size="14" />

View File

@@ -20,10 +20,8 @@ import Chart, {
type ScriptableLineSegmentContext, type ScriptableLineSegmentContext,
type TooltipItem, type TooltipItem,
} from 'chart.js/auto'; } from 'chart.js/auto';
import mapboxgl from 'mapbox-gl';
import { get, type Readable, type Writable } from 'svelte/store'; import { get, type Readable, type Writable } from 'svelte/store';
import { map } from '$lib/components/map/map'; import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import { mode } from 'mode-watcher'; import { mode } from 'mode-watcher';
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors'; import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
@@ -42,7 +40,7 @@ interface ElevationProfilePoint {
length: number; length: number;
}; };
extensions: Record<string, any>; extensions: Record<string, any>;
coordinates: [number, number]; coordinates: Coordinates;
index: number; index: number;
} }
@@ -50,18 +48,19 @@ export class ElevationProfile {
private _chart: Chart | null = null; private _chart: Chart | null = null;
private _canvas: HTMLCanvasElement; private _canvas: HTMLCanvasElement;
private _overlay: HTMLCanvasElement; private _overlay: HTMLCanvasElement;
private _marker: mapboxgl.Marker | null = null;
private _dragging = false; private _dragging = false;
private _panning = false; private _panning = false;
private _gpxStatistics: Readable<GPXStatisticsGroup>; private _gpxStatistics: Readable<GPXStatisticsGroup>;
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>; private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
private _hoveredPoint: Writable<Coordinates | null>;
private _additionalDatasets: Readable<string[]>; private _additionalDatasets: Readable<string[]>;
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>; private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
constructor( constructor(
gpxStatistics: Readable<GPXStatisticsGroup>, gpxStatistics: Readable<GPXStatisticsGroup>,
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>, slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
hoveredPoint: Writable<Coordinates | null>,
additionalDatasets: Readable<string[]>, additionalDatasets: Readable<string[]>,
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>, elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
@@ -69,17 +68,12 @@ export class ElevationProfile {
) { ) {
this._gpxStatistics = gpxStatistics; this._gpxStatistics = gpxStatistics;
this._slicedGPXStatistics = slicedGPXStatistics; this._slicedGPXStatistics = slicedGPXStatistics;
this._hoveredPoint = hoveredPoint;
this._additionalDatasets = additionalDatasets; this._additionalDatasets = additionalDatasets;
this._elevationFill = elevationFill; this._elevationFill = elevationFill;
this._canvas = canvas; this._canvas = canvas;
this._overlay = overlay; this._overlay = overlay;
let element = document.createElement('div');
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
this._marker = new mapboxgl.Marker({
element,
});
import('chartjs-plugin-zoom').then((module) => { import('chartjs-plugin-zoom').then((module) => {
Chart.register(module.default); Chart.register(module.default);
this.initialize(); this.initialize();
@@ -162,14 +156,10 @@ export class ElevationProfile {
label: (context: TooltipItem<'line'>) => { label: (context: TooltipItem<'line'>) => {
let point = context.raw as ElevationProfilePoint; let point = context.raw as ElevationProfilePoint;
if (context.datasetIndex === 0) { if (context.datasetIndex === 0) {
const map_ = get(map); if (this._dragging) {
if (map_ && this._marker) { this._hoveredPoint.set(null);
if (this._dragging) { } else {
this._marker.remove(); this._hoveredPoint.set(point.coordinates);
} else {
this._marker.setLngLat(point.coordinates);
this._marker.addTo(map_);
}
} }
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`; return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 1) { } else if (context.datasetIndex === 1) {
@@ -312,10 +302,7 @@ export class ElevationProfile {
events: ['mouseout'], events: ['mouseout'],
afterEvent: (chart: Chart, args: { event: ChartEvent }) => { afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
if (args.event.type === 'mouseout') { if (args.event.type === 'mouseout') {
const map_ = get(map); this._hoveredPoint.set(null);
if (map_ && this._marker) {
this._marker.remove();
}
} }
}, },
}, },
@@ -637,8 +624,5 @@ export class ElevationProfile {
this._chart.destroy(); this._chart.destroy();
this._chart = null; this._chart = null;
} }
if (this._marker) {
this._marker.remove();
}
} }
} }

View File

@@ -16,7 +16,7 @@
import { setMode } from 'mode-watcher'; import { setMode } from 'mode-watcher';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { fileStateCollection } from '$lib/logic/file-state'; import { fileStateCollection } from '$lib/logic/file-state';
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics'; import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
import { loadFile } from '$lib/logic/file-actions'; import { loadFile } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection'; import { selection } from '$lib/logic/selection';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
@@ -102,7 +102,7 @@
<div class="grow relative"> <div class="grow relative">
<Map <Map
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}" class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
accessToken={options.token} maptilerKey={options.key}
geocoder={false} geocoder={false}
geolocate={true} geolocate={true}
hash={useHash} hash={useHash}
@@ -117,19 +117,19 @@
{/if} {/if}
</div> </div>
<div <div
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 p-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}
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}
{hoveredPoint}
{additionalDatasets} {additionalDatasets}
{elevationFill} {elevationFill}
showControls={options.elevation.controls} showControls={options.elevation.controls}

View File

@@ -22,7 +22,7 @@
getCleanedEmbeddingOptions, getCleanedEmbeddingOptions,
getMergedEmbeddingOptions, getMergedEmbeddingOptions,
} from './embedding'; } from './embedding';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public'; import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
import Embedding from './Embedding.svelte'; import Embedding from './Embedding.svelte';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { base } from '$app/paths'; import { base } from '$app/paths';
@@ -32,7 +32,7 @@
let options = $state( let options = $state(
getMergedEmbeddingOptions( getMergedEmbeddingOptions(
{ {
token: 'YOUR_MAPBOX_TOKEN', key: 'YOUR_MAPTILER_KEY',
theme: mode.current, theme: mode.current,
}, },
defaultEmbeddingOptions defaultEmbeddingOptions
@@ -46,10 +46,10 @@
let iframeOptions = $derived( let iframeOptions = $derived(
getMergedEmbeddingOptions( getMergedEmbeddingOptions(
{ {
token: key:
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN' options.key.length === 0 || options.key === 'YOUR_MAPTILER_KEY'
? PUBLIC_MAPBOX_TOKEN ? PUBLIC_MAPTILER_KEY
: options.token, : options.key,
files: files.split(',').filter((url) => url.length > 0), files: files.split(',').filter((url) => url.length > 0),
ids: driveIds.split(',').filter((id) => id.length > 0), ids: driveIds.split(',').filter((id) => id.length > 0),
elevation: { elevation: {
@@ -102,8 +102,8 @@
</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="key">{i18n._('embedding.maptiler_key')}</Label>
<Input id="token" type="text" class="h-8" bind:value={options.token} /> <Input id="key" type="text" class="h-8" bind:value={options.key} />
<Label for="file_urls">{i18n._('embedding.file_urls')}</Label> <Label for="file_urls">{i18n._('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">{i18n._('embedding.drive_ids')}</Label>

View File

@@ -1,8 +1,8 @@
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public'; import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
import { basemaps } from '$lib/assets/layers'; import { basemaps } from '$lib/assets/layers';
export type EmbeddingOptions = { export type EmbeddingOptions = {
token: string; key: string;
files: string[]; files: string[];
ids: string[]; ids: string[];
basemap: string; basemap: string;
@@ -26,10 +26,10 @@ export type EmbeddingOptions = {
}; };
export const defaultEmbeddingOptions = { export const defaultEmbeddingOptions = {
token: '', key: '',
files: [], files: [],
ids: [], ids: [],
basemap: 'mapboxOutdoors', basemap: 'maptilerStreets',
elevation: { elevation: {
show: true, show: true,
height: 170, height: 170,
@@ -90,6 +90,9 @@ export function getCleanedEmbeddingOptions(
delete cleanedOptions[key]; delete cleanedOptions[key];
} }
} }
if (cleanedOptions['key'] && cleanedOptions['key'] === PUBLIC_MAPTILER_KEY) {
delete cleanedOptions['key'];
}
return cleanedOptions; return cleanedOptions;
} }
@@ -107,7 +110,7 @@ export function getURLForGoogleDriveFile(fileId: string): string {
export function convertOldEmbeddingOptions(options: URLSearchParams): any { export function convertOldEmbeddingOptions(options: URLSearchParams): any {
let newOptions: any = { let newOptions: any = {
token: PUBLIC_MAPBOX_TOKEN, key: PUBLIC_MAPTILER_KEY,
files: [], files: [],
ids: [], ids: [],
}; };
@@ -123,7 +126,7 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
if (options.has('source')) { if (options.has('source')) {
let basemap = options.get('source')!; let basemap = options.get('source')!;
if (basemap === 'satellite') { if (basemap === 'satellite') {
newOptions.basemap = 'mapboxSatellite'; newOptions.basemap = 'maptilerSatellite';
} else if (basemap === 'otm') { } else if (basemap === 'otm') {
newOptions.basemap = 'openTopoMap'; newOptions.basemap = 'openTopoMap';
} else if (basemap === 'ohm') { } else if (basemap === 'ohm') {

View File

@@ -100,7 +100,11 @@
</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://opencollective.com/gpxstudio"
target="_blank"
>
{i18n._('menu.support_button')} {i18n._('menu.support_button')}
<span>🙏</span> <span>🙏</span>
</Button> </Button>

View File

@@ -114,10 +114,10 @@
<ContextMenu.Trigger class="grow truncate"> <ContextMenu.Trigger class="grow truncate">
<Button <Button
variant="ghost" variant="ghost"
class="relative w-full p-0 overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation === class="relative w-full p-0 overflow-hidden border-none focus-visible:ring-0 focus-visible:ring-offset-0 flex flex-row {orientation ===
'vertical' 'vertical'
? 'h-fit' ? 'h-7'
: 'h-9 px-1.5 shadow-md'} pointer-events-auto" : 'h-9 px-1.5'} pointer-events-auto"
> >
{#if item instanceof ListFileItem || item instanceof ListTrackItem} {#if item instanceof ListFileItem || item instanceof ListTrackItem}
<MetadataDialog bind:open={openEditMetadata} {node} {item} /> <MetadataDialog bind:open={openEditMetadata} {node} {item} />
@@ -126,7 +126,7 @@
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK} {#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
<div <div
class="absolute {orientation === 'vertical' class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-1 w-1' ? 'top-0 bottom-0 right-0 w-1'
: 'top-0 h-1 left-0 right-0'}" : 'top-0 h-1 left-0 right-0'}"
style="background:linear-gradient(to {orientation === 'vertical' style="background:linear-gradient(to {orientation === 'vertical'
? 'bottom' ? 'bottom'
@@ -139,7 +139,7 @@
></div> ></div>
{/if} {/if}
<span <span
class="w-full text-left truncate py-1 flex flex-row items-center {hidden class="grow text-left truncate ml-1 flex flex-row items-center {hidden
? 'text-muted-foreground' ? 'text-muted-foreground'
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId()) : ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
? 'text-muted-foreground' ? 'text-muted-foreground'

View File

@@ -5,6 +5,16 @@
map.onLoad((map_) => { map.onLoad((map_) => {
map_.on('contextmenu', (e) => { map_.on('contextmenu', (e) => {
if (
map_.queryRenderedFeatures(e.point, {
layers: map_
.getLayersOrder()
.filter((layerId) => layerId.startsWith('routing-controls')),
}).length
) {
// Clicked on routing control, ignoring
return;
}
trackpointPopup?.setItem({ trackpointPopup?.setItem({
item: new TrackPoint({ item: new TrackPoint({
attributes: { attributes: {

View File

@@ -1,30 +1,25 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { page } from '$app/state'; import { page } from '$app/state';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
let { let {
accessToken = PUBLIC_MAPBOX_TOKEN, maptilerKey = PUBLIC_MAPTILER_KEY,
geolocate = true, geolocate = true,
geocoder = true, geocoder = true,
hash = true, hash = true,
class: className = '', class: className = '',
}: { }: {
accessToken?: string; maptilerKey?: string;
geolocate?: boolean; geolocate?: boolean;
geocoder?: boolean; geocoder?: boolean;
hash?: boolean; hash?: boolean;
class?: string; class?: string;
} = $props(); } = $props();
mapboxgl.accessToken = accessToken;
let webgl2Supported = $state(true); let webgl2Supported = $state(true);
let embeddedApp = $state(false); let embeddedApp = $state(false);
@@ -48,7 +43,7 @@
language = 'en'; language = 'en';
} }
map.init(language, hash, geocoder, geolocate); map.init(maptilerKey, language, hash, geocoder, geolocate);
}); });
onDestroy(() => { onDestroy(() => {
@@ -81,21 +76,21 @@
<style lang="postcss"> <style lang="postcss">
@reference "../../../app.css"; @reference "../../../app.css";
div :global(.mapboxgl-map) { div :global(.maplibregl-map) {
@apply font-sans; @apply font-sans;
} }
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) { div :global(.maplibregl-ctrl-top-right > .maplibregl-ctrl) {
@apply shadow-md; @apply shadow-md;
@apply bg-background; @apply bg-background;
@apply text-foreground; @apply text-foreground;
} }
div :global(.mapboxgl-ctrl-icon) { div :global(.maplibregl-ctrl-icon) {
@apply dark:brightness-[4.7]; @apply dark:brightness-[4.7];
} }
div :global(.mapboxgl-ctrl-geocoder) { div :global(.maplibregl-ctrl-geocoder) {
@apply flex; @apply flex;
@apply flex-row; @apply flex-row;
@apply w-fit; @apply w-fit;
@@ -110,36 +105,45 @@
@apply text-foreground; @apply text-foreground;
} }
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) { div :global(.maplibregl-ctrl-geocoder .suggestions > li > a) {
@apply text-foreground; @apply text-foreground;
@apply hover:text-accent-foreground; @apply hover:text-accent-foreground;
@apply hover:bg-accent; @apply hover:bg-accent;
} }
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) { div :global(.maplibregl-ctrl-geocoder .suggestions > .active > a) {
@apply bg-background; @apply bg-background;
} }
div :global(.mapboxgl-ctrl-geocoder--button) { div :global(.maplibregl-ctrl-geocoder--button) {
@apply bg-transparent; @apply bg-transparent;
@apply hover:bg-transparent; @apply hover:bg-transparent;
} }
div :global(.mapboxgl-ctrl-geocoder--icon) { div :global(.maplibregl-ctrl-geocoder--icon) {
@apply fill-foreground; @apply fill-foreground;
@apply hover:fill-accent-foreground; @apply hover:fill-accent-foreground;
} }
div :global(.mapboxgl-ctrl-geocoder--icon-search) { div :global(.maplibregl-ctrl-geocoder--icon-search) {
@apply relative; @apply relative;
@apply top-0; @apply top-0;
@apply left-0; @apply left-0;
@apply my-2;
@apply w-[29px]; @apply w-[29px];
} }
div :global(.mapboxgl-ctrl-geocoder--input) { div :global(.maplibregl-ctrl-geocoder--icon-loading) {
@apply -mt-1;
@apply mb-0;
}
div :global(.maplibregl-ctrl-geocoder--icon-close) {
@apply my-0;
}
div :global(.maplibregl-ctrl-geocoder--input) {
@apply relative; @apply relative;
@apply h-8;
@apply w-64; @apply w-64;
@apply py-0; @apply py-0;
@apply pl-2; @apply pl-2;
@@ -149,12 +153,12 @@
@apply text-foreground; @apply text-foreground;
} }
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) { div :global(.maplibregl-ctrl-geocoder--collapsed .maplibregl-ctrl-geocoder--input) {
@apply w-0; @apply w-0;
@apply p-0; @apply p-0;
} }
div :global(.mapboxgl-ctrl-top-right) { div :global(.maplibregl-ctrl-top-right) {
@apply z-40; @apply z-40;
@apply flex; @apply flex;
@apply flex-col; @apply flex-col;
@@ -163,77 +167,76 @@
@apply overflow-hidden; @apply overflow-hidden;
} }
.horizontal :global(.mapboxgl-ctrl-bottom-left) { .horizontal :global(.maplibregl-ctrl-bottom-left) {
@apply bottom-[42px]; @apply bottom-[42px];
} }
.horizontal :global(.mapboxgl-ctrl-bottom-right) { .horizontal :global(.maplibregl-ctrl-bottom-right) {
@apply bottom-[42px]; @apply bottom-[42px];
} }
div :global(.mapboxgl-ctrl-attrib) { div :global(.maplibregl-ctrl-attrib) {
@apply dark:bg-transparent; @apply dark:bg-transparent;
} }
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) { div :global(.maplibregl-compact-show.maplibregl-ctrl-attrib) {
@apply dark:bg-background; @apply dark:bg-background;
} }
div :global(.mapboxgl-ctrl-attrib-button) { div :global(.maplibregl-ctrl-attrib-button) {
@apply dark:bg-foreground; @apply dark:bg-foreground;
} }
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) { div :global(.maplibregl-compact-show .maplibregl-ctrl-attrib-button) {
@apply dark:bg-foreground; @apply dark:bg-foreground;
} }
div :global(.mapboxgl-ctrl-attrib a) { div :global(.maplibregl-ctrl-attrib a) {
@apply text-foreground; @apply text-foreground;
} }
div :global(.mapboxgl-popup) { div :global(.maplibregl-popup) {
@apply w-fit;
@apply z-50; @apply z-50;
} }
div :global(.mapboxgl-popup-content) { div :global(.maplibregl-popup-content) {
@apply p-0; @apply p-0;
@apply bg-transparent; @apply bg-transparent;
@apply shadow-none; @apply shadow-none;
} }
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) { div :global(.maplibregl-popup-anchor-top .maplibregl-popup-tip) {
@apply border-b-background; @apply border-b-background;
} }
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) { div :global(.maplibregl-popup-anchor-top-left .maplibregl-popup-tip) {
@apply border-b-background; @apply border-b-background;
} }
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) { div :global(.maplibregl-popup-anchor-top-right .maplibregl-popup-tip) {
@apply border-b-background; @apply border-b-background;
} }
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) { div :global(.maplibregl-popup-anchor-bottom .maplibregl-popup-tip) {
@apply border-t-background; @apply border-t-background;
@apply drop-shadow-md; @apply drop-shadow-md;
} }
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) { div :global(.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip) {
@apply border-t-background; @apply border-t-background;
@apply drop-shadow-md; @apply drop-shadow-md;
} }
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) { div :global(.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip) {
@apply border-t-background; @apply border-t-background;
@apply drop-shadow-md; @apply drop-shadow-md;
} }
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) { div :global(.maplibregl-popup-anchor-left .maplibregl-popup-tip) {
@apply border-r-background; @apply border-r-background;
} }
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) { div :global(.maplibregl-popup-anchor-right .maplibregl-popup-tip) {
@apply border-l-background; @apply border-l-background;
} }
</style> </style>

View File

@@ -17,7 +17,7 @@
let control: CustomControl | null = null; let control: CustomControl | null = null;
onMount(() => { onMount(() => {
map.onLoad((map: mapboxgl.Map) => { map.onLoad((map: maplibregl.Map) => {
if (position.includes('right')) container.classList.add('float-right'); if (position.includes('right')) container.classList.add('float-right');
else container.classList.add('float-left'); else container.classList.add('float-left');
container.classList.remove('hidden'); container.classList.remove('hidden');

View File

@@ -1,4 +1,4 @@
import { type Map, type IControl } from 'mapbox-gl'; import { type Map, type IControl } from 'maplibre-gl';
export default class CustomControl implements IControl { export default class CustomControl implements IControl {
_map: Map | undefined; _map: Map | undefined;

View File

@@ -16,7 +16,6 @@
</script> </script>
<Button <Button
size="sm"
class="justify-start {className}" class="justify-start {className}"
variant="outline" variant="outline"
onclick={() => { onclick={() => {

View File

@@ -39,7 +39,6 @@
/> />
{#if trackpoint.fileId === undefined} {#if trackpoint.fileId === undefined}
<Button <Button
size="sm"
variant="outline" variant="outline"
class="justify-start" class="justify-start"
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.getLatitude().toFixed(5)}/${trackpoint.item.getLongitude().toFixed(5)}`} href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.getLatitude().toFixed(5)}/${trackpoint.item.getLongitude().toFixed(5)}`}

View File

@@ -88,7 +88,6 @@
<CopyCoordinates coordinates={waypoint.item.attributes} /> <CopyCoordinates coordinates={waypoint.item.attributes} />
{#if $currentTool === Tool.WAYPOINT && selected} {#if $currentTool === Tool.WAYPOINT && selected}
<Button <Button
class="p-1 has-[>svg]:px-2 h-8"
variant="outline" variant="outline"
onclick={() => { onclick={() => {
if (waypoint.fileId) { if (waypoint.fileId) {

View File

@@ -1,10 +1,11 @@
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { gpxStatistics } from '$lib/logic/statistics'; import { gpxStatistics } from '$lib/logic/statistics';
import { getConvertedDistanceToKilometers } from '$lib/units'; import { getConvertedDistanceToKilometers } from '$lib/units';
import type { GeoJSONSource } from 'mapbox-gl';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { ANCHOR_LAYER_KEY, map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden'; import { allHidden } from '$lib/logic/hidden';
import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
const { distanceMarkers, distanceUnits } = settings; const { distanceMarkers, distanceUnits } = settings;
@@ -22,7 +23,7 @@ export class DistanceMarkers {
this.unsubscribes.push( this.unsubscribes.push(
map.subscribe((map_) => { map.subscribe((map_) => {
if (map_) { if (map_) {
map_.on('style.import.load', this.updateBinded); map_.on('style.load', this.updateBinded);
} }
}) })
); );

View File

@@ -3,13 +3,14 @@ import { MapPopup } from '$lib/components/map/map-popup';
export let waypointPopup: MapPopup | null = null; export let waypointPopup: MapPopup | null = null;
export let trackpointPopup: MapPopup | null = null; export let trackpointPopup: MapPopup | null = null;
export function createPopups(map: mapboxgl.Map) { export function createPopups(map: maplibregl.Map) {
removePopups(); removePopups();
waypointPopup = new MapPopup(map, { waypointPopup = new MapPopup(map, {
closeButton: false, closeButton: false,
focusAfterOpen: false, focusAfterOpen: false,
maxWidth: undefined, maxWidth: undefined,
offset: { offset: {
center: [0, 0],
top: [0, 0], top: [0, 0],
'top-left': [0, 0], 'top-left': [0, 0],
'top-right': [0, 0], 'top-right': [0, 0],

View File

@@ -1,6 +1,11 @@
import { get, type Readable } from 'svelte/store'; import { get, type Readable } from 'svelte/store';
import mapboxgl, { type FilterSpecification } from 'mapbox-gl'; import maplibregl, {
import { ANCHOR_LAYER_KEY, map } from '$lib/components/map/map'; type GeoJSONSource,
type FilterSpecification,
type MapLayerMouseEvent,
type MapLayerTouchEvent,
} from 'maplibre-gl';
import { map } from '$lib/components/map/map';
import { waypointPopup, trackpointPopup } from './gpx-layer-popup'; import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
import { import {
ListTrackSegmentItem, ListTrackSegmentItem,
@@ -10,7 +15,7 @@ import {
ListFileItem, ListFileItem,
ListRootItem, ListRootItem,
} from '$lib/components/file-list/file-list'; } from '$lib/components/file-list/file-list';
import { getClosestLinePoint, getElevation } from '$lib/utils'; import { getClosestLinePoint, getElevation, loadSVGIcon } from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint'; import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
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';
@@ -22,6 +27,7 @@ import { fileActionManager } from '$lib/logic/file-action-manager';
import { fileActions } from '$lib/logic/file-actions'; import { fileActions } from '$lib/logic/file-actions';
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors'; import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { gpxColors } from '$lib/components/map/gpx-layer/gpx-layers'; import { gpxColors } from '$lib/components/map/gpx-layer/gpx-layers';
const colors = [ const colors = [
@@ -114,28 +120,28 @@ export class GPXLayer {
selected: boolean = false; selected: boolean = false;
currentWaypointData: GeoJSON.FeatureCollection | null = null; currentWaypointData: GeoJSON.FeatureCollection | null = null;
draggedWaypointIndex: number | null = null; draggedWaypointIndex: number | null = null;
draggingStartingPosition: mapboxgl.Point = new mapboxgl.Point(0, 0); draggingStartingPosition: maplibregl.Point = new maplibregl.Point(0, 0);
unsubscribe: Function[] = []; unsubscribe: Function[] = [];
updateBinded: () => void = this.update.bind(this); updateBinded: () => void = this.update.bind(this);
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this); layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this); layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this); layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this); layerOnClickBinded: (e: MapLayerMouseEvent) => void = this.layerOnClick.bind(this);
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this); layerOnContextMenuBinded: (e: MapLayerMouseEvent) => void = this.layerOnContextMenu.bind(this);
waypointLayerOnMouseEnterBinded: (e: mapboxgl.MapMouseEvent) => void = waypointLayerOnMouseEnterBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnMouseEnter.bind(this); this.waypointLayerOnMouseEnter.bind(this);
waypointLayerOnMouseLeaveBinded: (e: mapboxgl.MapMouseEvent) => void = waypointLayerOnMouseLeaveBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnMouseLeave.bind(this); this.waypointLayerOnMouseLeave.bind(this);
waypointLayerOnClickBinded: (e: mapboxgl.MapMouseEvent) => void = waypointLayerOnClickBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnClick.bind(this); this.waypointLayerOnClick.bind(this);
waypointLayerOnMouseDownBinded: (e: mapboxgl.MapMouseEvent) => void = waypointLayerOnMouseDownBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnMouseDown.bind(this); this.waypointLayerOnMouseDown.bind(this);
waypointLayerOnTouchStartBinded: (e: mapboxgl.MapTouchEvent) => void = waypointLayerOnTouchStartBinded: (e: MapLayerTouchEvent) => void =
this.waypointLayerOnTouchStart.bind(this); this.waypointLayerOnTouchStart.bind(this);
waypointLayerOnMouseMoveBinded: (e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) => void = waypointLayerOnMouseMoveBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
this.waypointLayerOnMouseMove.bind(this); this.waypointLayerOnMouseMove.bind(this);
waypointLayerOnMouseUpBinded: (e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) => void = waypointLayerOnMouseUpBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
this.waypointLayerOnMouseUp.bind(this); this.waypointLayerOnMouseUp.bind(this);
constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) { constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
@@ -145,7 +151,7 @@ export class GPXLayer {
this.unsubscribe.push( this.unsubscribe.push(
map.subscribe(($map) => { map.subscribe(($map) => {
if ($map) { if ($map) {
$map.on('style.import.load', this.updateBinded); $map.on('style.load', this.updateBinded);
this.update(); this.update();
} }
}) })
@@ -168,8 +174,9 @@ export class GPXLayer {
update() { update() {
const _map = get(map); const _map = get(map);
const layerEventManager = map.layerEventManager;
let file = get(this.file)?.file; let file = get(this.file)?.file;
if (!_map || !file) { if (!_map || !layerEventManager || !file) {
return; return;
} }
@@ -185,7 +192,7 @@ export class GPXLayer {
this.loadIcons(); this.loadIcons();
try { try {
let source = _map.getSource(this.fileId) as mapboxgl.GeoJSONSource | undefined; let source = _map.getSource(this.fileId) as GeoJSONSource | undefined;
if (source) { if (source) {
source.setData(this.getGeoJSON()); source.setData(this.getGeoJSON());
} else { } else {
@@ -214,15 +221,63 @@ export class GPXLayer {
ANCHOR_LAYER_KEY.tracks ANCHOR_LAYER_KEY.tracks
); );
_map.on('click', this.fileId, this.layerOnClickBinded); layerEventManager.on('click', this.fileId, this.layerOnClickBinded);
_map.on('contextmenu', this.fileId, this.layerOnContextMenuBinded); layerEventManager.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
_map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded); layerEventManager.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
_map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded); layerEventManager.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
_map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded); layerEventManager.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
}
let visibleTrackSegmentIds: string[] = [];
file.forEachSegment((segment, trackIndex, segmentIndex) => {
if (!segment._data.hidden) {
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
}
});
const segmentFilter: FilterSpecification = [
'in',
['get', 'trackSegmentId'],
['literal', visibleTrackSegmentIds],
];
_map.setFilter(this.fileId, segmentFilter, { validate: false });
if (get(directionMarkers)) {
if (!_map.getLayer(this.fileId + '-direction')) {
_map.addLayer(
{
id: this.fileId + '-direction',
type: 'symbol',
source: this.fileId,
layout: {
'text-field': '»',
'text-offset': [0, -0.06],
'text-keep-upright': false,
'text-max-angle': 361,
'text-allow-overlap': true,
'text-font': ['Open Sans Bold'],
'symbol-placement': 'line',
'symbol-spacing': 20,
},
paint: {
'text-color': 'white',
'text-halo-width': 0.2,
'text-halo-color': 'white',
},
},
ANCHOR_LAYER_KEY.directionMarkers
);
}
_map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false });
} else {
if (_map.getLayer(this.fileId + '-direction')) {
_map.removeLayer(this.fileId + '-direction');
}
} }
let waypointSource = _map.getSource(this.fileId + '-waypoints') as let waypointSource = _map.getSource(this.fileId + '-waypoints') as
| mapboxgl.GeoJSONSource | GeoJSONSource
| undefined; | undefined;
this.currentWaypointData = this.getWaypointsGeoJSON(); this.currentWaypointData = this.getWaypointsGeoJSON();
if (waypointSource) { if (waypointSource) {
@@ -231,6 +286,7 @@ export class GPXLayer {
_map.addSource(this.fileId + '-waypoints', { _map.addSource(this.fileId + '-waypoints', {
type: 'geojson', type: 'geojson',
data: this.currentWaypointData, data: this.currentWaypointData,
promoteId: 'waypointIndex',
}); });
} }
@@ -251,80 +307,33 @@ export class GPXLayer {
ANCHOR_LAYER_KEY.waypoints ANCHOR_LAYER_KEY.waypoints
); );
_map.on( layerEventManager.on(
'mouseenter', 'mouseenter',
this.fileId + '-waypoints', this.fileId + '-waypoints',
this.waypointLayerOnMouseEnterBinded this.waypointLayerOnMouseEnterBinded
); );
_map.on( layerEventManager.on(
'mouseleave', 'mouseleave',
this.fileId + '-waypoints', this.fileId + '-waypoints',
this.waypointLayerOnMouseLeaveBinded this.waypointLayerOnMouseLeaveBinded
); );
_map.on('click', this.fileId + '-waypoints', this.waypointLayerOnClickBinded); layerEventManager.on(
_map.on( 'click',
this.fileId + '-waypoints',
this.waypointLayerOnClickBinded
);
layerEventManager.on(
'mousedown', 'mousedown',
this.fileId + '-waypoints', this.fileId + '-waypoints',
this.waypointLayerOnMouseDownBinded this.waypointLayerOnMouseDownBinded
); );
_map.on( layerEventManager.on(
'touchstart', 'touchstart',
this.fileId + '-waypoints', this.fileId + '-waypoints',
this.waypointLayerOnTouchStartBinded this.waypointLayerOnTouchStartBinded
); );
} }
if (get(directionMarkers)) {
if (!_map.getLayer(this.fileId + '-direction')) {
_map.addLayer(
{
id: this.fileId + '-direction',
type: 'symbol',
source: this.fileId,
layout: {
'text-field': '»',
'text-offset': [0, -0.1],
'text-keep-upright': false,
'text-max-angle': 361,
'text-allow-overlap': true,
'text-font': ['Open Sans Bold'],
'symbol-placement': 'line',
'symbol-spacing': 20,
},
paint: {
'text-color': 'white',
'text-opacity': 0.7,
'text-halo-width': 0.2,
'text-halo-color': 'white',
},
},
ANCHOR_LAYER_KEY.directionMarkers
);
}
} else {
if (_map.getLayer(this.fileId + '-direction')) {
_map.removeLayer(this.fileId + '-direction');
}
}
let visibleTrackSegmentIds: string[] = [];
file.forEachSegment((segment, trackIndex, segmentIndex) => {
if (!segment._data.hidden) {
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
}
});
const segmentFilter: FilterSpecification = [
'in',
['get', 'trackSegmentId'],
['literal', visibleTrackSegmentIds],
];
_map.setFilter(this.fileId, segmentFilter, { validate: false });
if (_map.getLayer(this.fileId + '-direction')) {
_map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false });
}
let visibleWaypoints: number[] = []; let visibleWaypoints: number[] = [];
file.wpt.forEach((waypoint, waypointIndex) => { file.wpt.forEach((waypoint, waypointIndex) => {
if (!waypoint._data.hidden) { if (!waypoint._data.hidden) {
@@ -345,32 +354,47 @@ export class GPXLayer {
remove() { remove() {
const _map = get(map); const _map = get(map);
if (_map) {
_map.off('click', this.fileId, this.layerOnClickBinded);
_map.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
_map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
_map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
_map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
_map.off('style.import.load', this.updateBinded);
_map.off( if (_map) {
_map.off('style.load', this.updateBinded);
}
const layerEventManager = map.layerEventManager;
if (layerEventManager) {
layerEventManager.off('click', this.fileId, this.layerOnClickBinded);
layerEventManager.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
layerEventManager.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
layerEventManager.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
layerEventManager.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
layerEventManager.off(
'mouseenter', 'mouseenter',
this.fileId + '-waypoints', this.fileId + '-waypoints',
this.waypointLayerOnMouseEnterBinded this.waypointLayerOnMouseEnterBinded
); );
_map.off( layerEventManager.off(
'mouseleave', 'mouseleave',
this.fileId + '-waypoints', this.fileId + '-waypoints',
this.waypointLayerOnMouseLeaveBinded this.waypointLayerOnMouseLeaveBinded
); );
_map.off('click', this.fileId + '-waypoints', this.waypointLayerOnClickBinded); layerEventManager.off(
_map.off('mousedown', this.fileId + '-waypoints', this.waypointLayerOnMouseDownBinded); 'click',
_map.off( this.fileId + '-waypoints',
this.waypointLayerOnClickBinded
);
layerEventManager.off(
'mousedown',
this.fileId + '-waypoints',
this.waypointLayerOnMouseDownBinded
);
layerEventManager.off(
'touchstart', 'touchstart',
this.fileId + '-waypoints', this.fileId + '-waypoints',
this.waypointLayerOnTouchStartBinded this.waypointLayerOnTouchStartBinded
); );
}
if (_map) {
if (_map.getLayer(this.fileId + '-direction')) { if (_map.getLayer(this.fileId + '-direction')) {
_map.removeLayer(this.fileId + '-direction'); _map.removeLayer(this.fileId + '-direction');
} }
@@ -446,7 +470,7 @@ export class GPXLayer {
} }
} }
layerOnClick(e: mapboxgl.MapMouseEvent) { layerOnClick(e: MapLayerMouseEvent) {
if ( if (
get(currentTool) === Tool.ROUTING && get(currentTool) === Tool.ROUTING &&
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
@@ -504,7 +528,7 @@ export class GPXLayer {
} }
} }
waypointLayerOnMouseEnter(e: mapboxgl.MapMouseEvent) { waypointLayerOnMouseEnter(e: MapLayerMouseEvent) {
if (this.draggedWaypointIndex !== null) { if (this.draggedWaypointIndex !== null) {
return; return;
} }
@@ -524,7 +548,7 @@ export class GPXLayer {
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false); mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false);
} }
waypointLayerOnClick(e: mapboxgl.MapMouseEvent) { waypointLayerOnClick(e: MapLayerMouseEvent) {
e.preventDefault(); e.preventDefault();
let waypointIndex = e.features![0].properties!.waypointIndex; let waypointIndex = e.features![0].properties!.waypointIndex;
@@ -566,7 +590,7 @@ export class GPXLayer {
} }
} }
waypointLayerOnMouseDown(e: mapboxgl.MapMouseEvent) { waypointLayerOnMouseDown(e: MapLayerMouseEvent) {
if (get(currentTool) !== Tool.WAYPOINT || !this.selected) { if (get(currentTool) !== Tool.WAYPOINT || !this.selected) {
return; return;
} }
@@ -576,6 +600,7 @@ export class GPXLayer {
} }
e.preventDefault(); e.preventDefault();
_map.dragPan.disable();
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex; this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
this.draggingStartingPosition = e.point; this.draggingStartingPosition = e.point;
@@ -585,7 +610,7 @@ export class GPXLayer {
_map.once('mouseup', this.waypointLayerOnMouseUpBinded); _map.once('mouseup', this.waypointLayerOnMouseUpBinded);
} }
waypointLayerOnTouchStart(e: mapboxgl.MapTouchEvent) { waypointLayerOnTouchStart(e: MapLayerTouchEvent) {
if (e.points.length !== 1 || get(currentTool) !== Tool.WAYPOINT || !this.selected) { if (e.points.length !== 1 || get(currentTool) !== Tool.WAYPOINT || !this.selected) {
return; return;
} }
@@ -599,12 +624,13 @@ export class GPXLayer {
waypointPopup?.hide(); waypointPopup?.hide();
e.preventDefault(); e.preventDefault();
_map.dragPan.disable();
_map.on('touchmove', this.waypointLayerOnMouseMoveBinded); _map.on('touchmove', this.waypointLayerOnMouseMoveBinded);
_map.once('touchend', this.waypointLayerOnMouseUpBinded); _map.once('touchend', this.waypointLayerOnMouseUpBinded);
} }
waypointLayerOnMouseMove(e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) { waypointLayerOnMouseMove(e: MapLayerMouseEvent | MapLayerTouchEvent) {
if (this.draggedWaypointIndex === null || e.point.equals(this.draggingStartingPosition)) { if (this.draggedWaypointIndex === null || e.point.equals(this.draggingStartingPosition)) {
return; return;
} }
@@ -616,18 +642,35 @@ export class GPXLayer {
).coordinates = [e.lngLat.lng, e.lngLat.lat]; ).coordinates = [e.lngLat.lng, e.lngLat.lat];
let waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as let waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as
| mapboxgl.GeoJSONSource | GeoJSONSource
| undefined; | undefined;
if (waypointSource) { if (waypointSource) {
waypointSource.setData(this.currentWaypointData!); waypointSource.updateData({
update: [
{
id: this.draggedWaypointIndex,
newGeometry: {
type: 'Point',
coordinates: [e.lngLat.lng, e.lngLat.lat],
},
},
],
});
} }
} }
waypointLayerOnMouseUp(e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) { waypointLayerOnMouseUp(e: MapLayerMouseEvent | MapLayerTouchEvent) {
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false); mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
get(map)?.off('mousemove', this.waypointLayerOnMouseMoveBinded); const _map = get(map);
get(map)?.off('touchmove', this.waypointLayerOnMouseMoveBinded); if (!_map) {
return;
}
_map.dragPan.enable();
_map.off('mousemove', this.waypointLayerOnMouseMoveBinded);
_map.off('touchmove', this.waypointLayerOnMouseMoveBinded);
if (this.draggedWaypointIndex === null) { if (this.draggedWaypointIndex === null) {
return; return;
@@ -750,20 +793,7 @@ export class GPXLayer {
symbols.forEach((symbol) => { symbols.forEach((symbol) => {
const iconId = `waypoint-${symbol ?? 'default'}-${this.layerColor}`; const iconId = `waypoint-${symbol ?? 'default'}-${this.layerColor}`;
if (!_map.hasImage(iconId)) { loadSVGIcon(_map, iconId, getSvgForSymbol(symbol, this.layerColor));
let icon = new Image(100, 100);
icon.onload = () => {
if (!_map.hasImage(iconId)) {
_map.addImage(iconId, icon);
}
};
// Lucide icons are SVG files with a 24x24 viewBox
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
icon.src =
'data:image/svg+xml,' +
encodeURIComponent(getSvgForSymbol(symbol, this.layerColor));
}
}); });
} }
} }

View File

@@ -1,30 +1,40 @@
import { currentTool, Tool } from '$lib/components/toolbar/tools'; import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics'; import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
import mapboxgl from 'mapbox-gl'; import type { GeoJSONSource } from 'maplibre-gl';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden'; import { allHidden } from '$lib/logic/hidden';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { loadSVGIcon } from '$lib/utils';
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6" fill="#22c55e" stroke="white" stroke-width="1.5"/>
</svg>`;
const endMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="checkerboard" x="0" y="0" width="5" height="5" patternUnits="userSpaceOnUse">
<rect x="0" y="0" width="2.5" height="2.5" fill="white"/>
<rect x="2.5" y="2.5" width="2.5" height="2.5" fill="white"/>
<rect x="2.5" y="0" width="2.5" height="2.5" fill="black"/>
<rect x="0" y="2.5" width="2.5" height="2.5" fill="black"/>
</pattern>
</defs>
<circle cx="8" cy="8" r="6" fill="url(#checkerboard)" stroke="white" stroke-width="1.5"/>
</svg>`;
const hoverMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6" fill="#00b8db" stroke="white" stroke-width="1.5"/>
</svg>`;
export class StartEndMarkers { export class StartEndMarkers {
start: mapboxgl.Marker;
end: mapboxgl.Marker;
updateBinded: () => void = this.update.bind(this); updateBinded: () => void = this.update.bind(this);
unsubscribes: (() => void)[] = []; unsubscribes: (() => void)[] = [];
constructor() { constructor() {
let startElement = document.createElement('div'); map.onLoad((map_) => map_.on('style.load', this.updateBinded));
let endElement = document.createElement('div');
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
endElement.className = `h-4 w-4 rounded-full border-2 border-white`;
endElement.style.background =
'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
this.start = new mapboxgl.Marker({ element: startElement });
this.end = new mapboxgl.Marker({ element: endElement });
map.onLoad(() => this.update());
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded)); this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded)); this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(hoveredPoint.subscribe(this.updateBinded));
this.unsubscribes.push(currentTool.subscribe(this.updateBinded)); this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
this.unsubscribes.push(allHidden.subscribe(this.updateBinded)); this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
} }
@@ -33,33 +43,115 @@ export class StartEndMarkers {
const map_ = get(map); const map_ = get(map);
if (!map_) return; if (!map_) return;
this.loadIcons();
const tool = get(currentTool); const tool = get(currentTool);
const statistics = get(gpxStatistics); const statistics = get(gpxStatistics);
const slicedStatistics = get(slicedGPXStatistics); const slicedStatistics = get(slicedGPXStatistics);
const hovered = get(hoveredPoint);
const hidden = get(allHidden); const hidden = get(allHidden);
if (statistics.global.length > 0 && tool !== Tool.ROUTING && !hidden) { if (!hidden) {
this.start const data: GeoJSON.FeatureCollection = {
.setLngLat( type: 'FeatureCollection',
statistics.getTrackPoint(slicedStatistics?.[1] ?? 0)!.trkpt.getCoordinates() features: [],
) };
.addTo(map_);
this.end if (statistics.global.length > 0 && tool !== Tool.ROUTING) {
.setLngLat( const start = statistics
statistics .getTrackPoint(slicedStatistics?.[1] ?? 0)!
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)! .trkpt.getCoordinates();
.trkpt.getCoordinates() const end = statistics
) .getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
.addTo(map_); .trkpt.getCoordinates();
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [start.lon, start.lat],
},
properties: {
icon: 'start-marker',
},
});
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [end.lon, end.lat],
},
properties: {
icon: 'end-marker',
},
});
}
if (hovered) {
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [hovered.lon, hovered.lat],
},
properties: {
icon: 'hover-marker',
},
});
}
let source = map_.getSource('start-end-markers') as GeoJSONSource | undefined;
if (source) {
source.setData(data);
} else {
map_.addSource('start-end-markers', {
type: 'geojson',
data: data,
});
}
if (!map_.getLayer('start-end-markers')) {
map_.addLayer(
{
id: 'start-end-markers',
type: 'symbol',
source: 'start-end-markers',
layout: {
'icon-image': ['get', 'icon'],
'icon-size': 0.2,
'icon-allow-overlap': true,
},
},
ANCHOR_LAYER_KEY.startEndMarkers
);
}
} else { } else {
this.start.remove(); if (map_.getLayer('start-end-markers')) {
this.end.remove(); map_.removeLayer('start-end-markers');
}
if (map_.getSource('start-end-markers')) {
map_.removeSource('start-end-markers');
}
} }
} }
remove() { remove() {
this.unsubscribes.forEach((unsubscribe) => unsubscribe()); this.unsubscribes.forEach((unsubscribe) => unsubscribe());
this.start.remove(); const map_ = get(map);
this.end.remove(); if (!map_) return;
if (map_.getLayer('start-end-markers')) {
map_.removeLayer('start-end-markers');
}
if (map_.getSource('start-end-markers')) {
map_.removeSource('start-end-markers');
}
}
loadIcons() {
const map_ = get(map);
if (!map_) return;
loadSVGIcon(map_, 'start-marker', startMarkerSVG);
loadSVGIcon(map_, 'end-marker', endMarkerSVG);
loadSVGIcon(map_, 'hover-marker', hoverMarkerSVG);
} }
} }

View File

@@ -20,9 +20,8 @@
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers'; import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { customBasemapUpdate, isSelected, remove } from './utils'; import { remove } from './utils';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { map } from '$lib/components/map/map';
import { dndzone } from 'svelte-dnd-action'; import { dndzone } from 'svelte-dnd-action';
const { const {
@@ -42,13 +41,8 @@
let maxZoom: number = $state(20); let maxZoom: number = $state(20);
let layerType: 'basemap' | 'overlay' = $state('basemap'); let layerType: 'basemap' | 'overlay' = $state('basemap');
let resourceType: 'raster' | 'vector' = $derived.by(() => { let resourceType: 'raster' | 'vector' = $derived.by(() => {
if (tileUrls[0].length > 0) { if (tileUrls[0].length > 0 && tileUrls[0].includes('.json')) {
if ( return 'vector';
tileUrls[0].includes('.json') ||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
) {
return 'vector';
}
} }
return 'raster'; return 'raster';
}); });
@@ -134,8 +128,8 @@
], ],
}; };
} }
$customLayers[layerId] = layer;
addLayer(layerId); addLayer(layerId);
$customLayers[layerId] = layer;
selectedLayerId = undefined; selectedLayerId = undefined;
setDataFromSelectedLayer(); setDataFromSelectedLayer();
} }
@@ -158,9 +152,7 @@
return $tree; return $tree;
}); });
if ($currentBasemap === layerId) { if ($currentBasemap !== layerId) {
$customBasemapUpdate++;
} else {
$currentBasemap = layerId; $currentBasemap = layerId;
} }
@@ -176,14 +168,6 @@
return $tree; return $tree;
}); });
if ($map && $currentOverlays && isSelected($currentOverlays, layerId)) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
currentOverlays.update(($overlays) => { currentOverlays.update(($overlays) => {
if (!$overlays.overlays.hasOwnProperty('custom')) { if (!$overlays.overlays.hasOwnProperty('custom')) {
$overlays.overlays['custom'] = {}; $overlays.overlays['custom'] = {};
@@ -248,120 +232,127 @@
<div class="flex flex-col"> <div class="flex flex-col">
{#if $customBasemapOrder.length > 0} {#if $customBasemapOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2"> <div class="px-3 py-2">
<Map size="16" /> <div class="flex flex-row items-center gap-1 font-semibold mb-2">
{i18n._('layers.label.basemaps')} <Map size="16" />
<div class="grow"> {i18n._('layers.label.basemaps')}
<Separator /> </div>
<div
class="ml-1.5 flex flex-col gap-1"
use:dndzone={{
items: customBasemapItems,
type: 'basemap',
dropTargetStyle: {},
transformDraggedElement: (element) => {
if (element) {
element.style.opacity = '0.5';
}
},
}}
onconsider={(e) => {
customBasemapItems = e.detail.items;
}}
onfinalize={(e) => {
customBasemapItems = e.detail.items;
$customBasemapOrder = customBasemapItems.map((item) => item.id);
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce(
(acc, item) => {
acc[item.id] = true;
return acc;
},
{}
);
}}
>
{#each customBasemapItems as item (item.id)}
<div class="flex flex-row items-center gap-1">
<Move size="12" />
<span class="grow">{item.name}</span>
<Button
variant="outline"
size="icon-sm"
onclick={() => (selectedLayerId = item.id)}
class="p-1 h-7"
>
<Pencil size="16" />
</Button>
<Button
variant="outline"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div> </div>
</div> </div>
<Separator />
{/if} {/if}
<div
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
use:dndzone={{
items: customBasemapItems,
type: 'basemap',
dropTargetStyle: {},
transformDraggedElement: (element) => {
if (element) {
element.style.opacity = '0.5';
}
},
}}
onconsider={(e) => {
customBasemapItems = e.detail.items;
}}
onfinalize={(e) => {
customBasemapItems = e.detail.items;
$customBasemapOrder = customBasemapItems.map((item) => item.id);
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce((acc, item) => {
acc[item.id] = true;
return acc;
}, {});
}}
>
{#each customBasemapItems as item (item.id)}
<div class="flex flex-row items-center gap-2">
<Move size="12" />
<span class="grow">{item.name}</span>
<Button
variant="outline"
size="icon-sm"
onclick={() => (selectedLayerId = item.id)}
class="p-1 h-7"
>
<Pencil size="16" />
</Button>
<Button
variant="outline"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
{#if $customOverlayOrder.length > 0} {#if $customOverlayOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2"> <div class="px-3 py-2">
<Layers2 size="16" /> <div class="flex flex-row items-center gap-1 font-semibold mb-2">
{i18n._('layers.label.overlays')} <Layers2 size="16" />
<div class="grow"> {i18n._('layers.label.overlays')}
<Separator /> <div class="grow"></div>
</div>
<div
class="ml-1.5 flex flex-col gap-1"
use:dndzone={{
items: customOverlayItems,
type: 'overlay',
dropTargetStyle: {},
transformDraggedElement: (element) => {
if (element) {
element.style.opacity = '0.5';
}
},
}}
onconsider={(e) => {
customOverlayItems = e.detail.items;
}}
onfinalize={(e) => {
customOverlayItems = e.detail.items;
$customOverlayOrder = customOverlayItems.map((item) => item.id);
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce(
(acc, item) => {
acc[item.id] = true;
return acc;
},
{}
);
}}
>
{#each customOverlayItems as item (item.id)}
<div class="flex flex-row items-center gap-1">
<Move size="12" />
<span class="grow">{item.name}</span>
<Button
variant="outline"
size="icon-sm"
onclick={() => (selectedLayerId = item.id)}
class="p-1 h-7"
>
<Pencil size="16" />
</Button>
<Button
variant="outline"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div> </div>
</div> </div>
<Separator />
{/if} {/if}
<div <Card.Root class="py-0 gap-0 shadow-none ring-0">
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
use:dndzone={{
items: customOverlayItems,
type: 'overlay',
dropTargetStyle: {},
transformDraggedElement: (element) => {
if (element) {
element.style.opacity = '0.5';
}
},
}}
onconsider={(e) => {
customOverlayItems = e.detail.items;
}}
onfinalize={(e) => {
customOverlayItems = e.detail.items;
$customOverlayOrder = customOverlayItems.map((item) => item.id);
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce((acc, item) => {
acc[item.id] = true;
return acc;
}, {});
}}
>
{#each customOverlayItems as item (item.id)}
<div class="flex flex-row items-center gap-2">
<Move size="12" />
<span class="grow">{item.name}</span>
<Button
variant="outline"
size="icon-sm"
onclick={() => (selectedLayerId = item.id)}
class="p-1 h-7"
>
<Pencil size="16" />
</Button>
<Button
variant="outline"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
<Card.Root class="py-0 gap-0 shadow-none">
<Card.Header class="p-3"> <Card.Header class="p-3">
<Card.Title class="text-base"> <Card.Title class="text-sm font-semibold">
{#if selectedLayerId} {#if selectedLayerId}
{i18n._('layers.custom_layers.edit')} {i18n._('layers.custom_layers.edit')}
{:else} {:else}
@@ -369,7 +360,7 @@
{/if} {/if}
</Card.Title> </Card.Title>
</Card.Header> </Card.Header>
<Card.Content class="p-3 pt-0"> <Card.Content class="px-3 py-2">
<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">{i18n._('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="h-8" /> <Input bind:value={name} id="name" class="h-8" />
@@ -426,7 +417,7 @@
</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-1">
<Button variant="outline" onclick={createLayer} class="grow"> <Button variant="outline" onclick={createLayer} class="grow">
<Save size="16" /> <Save size="16" />
{i18n._('layers.custom_layers.update')} {i18n._('layers.custom_layers.update')}

View File

@@ -5,12 +5,8 @@
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 { Layers } from '@lucide/svelte'; import { Layers } from '@lucide/svelte';
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { customBasemapUpdate, getLayers } from './utils';
import type { ImportSpecification, StyleSpecification } from 'mapbox-gl';
import { untrack } from 'svelte';
let container: HTMLDivElement; let container: HTMLDivElement;
let overpassLayer: OverpassLayer; let overpassLayer: OverpassLayer;
@@ -23,125 +19,14 @@
selectedBasemapTree, selectedBasemapTree,
selectedOverlayTree, selectedOverlayTree,
selectedOverpassTree, selectedOverpassTree,
customLayers,
opacities,
} = settings; } = settings;
function setStyle() { map.onLoad((_map: maplibregl.Map) => {
if (!$map) {
return;
}
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',
url: '',
data: basemap as StyleSpecification,
},
'overlays'
);
}
}
$effect(() => {
if ($map && ($currentBasemap || $customBasemapUpdate)) {
untrack(() => setStyle());
}
});
function addOverlay(id: string) {
if (!$map) {
return;
}
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 as StyleSpecification).layers.map((layer) => {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = $opacities[id];
}
return layer;
}),
};
}
$map.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 && $currentOverlays && $opacities) {
let overlayLayers = getLayers($currentOverlays);
try {
let activeOverlays =
$map
.getStyle()
.imports?.reduce(
(
acc: Record<string, ImportSpecification>,
imprt: ImportSpecification
) => {
if (!['basemap', 'overlays'].includes(imprt.id)) {
acc[imprt.id] = imprt;
}
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
}
}
}
$effect(() => {
if ($map && $currentOverlays && $opacities) {
untrack(() => updateOverlays());
}
});
map.onLoad((_map: mapboxgl.Map) => {
if (overpassLayer) { if (overpassLayer) {
overpassLayer.remove(); overpassLayer.remove();
} }
overpassLayer = new OverpassLayer(_map); overpassLayer = new OverpassLayer(_map, map.layerEventManager!);
overpassLayer.add(); overpassLayer.add();
let first = true;
_map.on('style.import.load', () => {
if (!first) return;
first = false;
updateOverlays();
});
}); });
let open = $state(false); let open = $state(false);

View File

@@ -121,7 +121,7 @@
<Accordion.Root class="flex flex-col" bind:value={accordionValue} type="single"> <Accordion.Root class="flex flex-col" bind:value={accordionValue} type="single">
<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>{i18n._('layers.selection')}</Accordion.Trigger>
<Accordion.Content class="grow flex flex-col border rounded"> <Accordion.Content class="grow flex flex-col border rounded-md mb-1.5">
<div class="py-2 pl-3 pr-2"> <div class="py-2 pl-3 pr-2">
<LayerTree <LayerTree
layerTree={basemapTree} layerTree={basemapTree}
@@ -152,7 +152,9 @@
</Accordion.Item> </Accordion.Item>
<Accordion.Item value="overlay-opacity"> <Accordion.Item value="overlay-opacity">
<Accordion.Trigger>{i18n._('layers.opacity')}</Accordion.Trigger> <Accordion.Trigger>{i18n._('layers.opacity')}</Accordion.Trigger>
<Accordion.Content class="flex flex-col gap-3 overflow-visible"> <Accordion.Content
class="flex flex-col gap-3 overflow-visible border rounded-md px-3 py-2 mb-1.5"
>
<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')} {i18n._('layers.custom_layers.overlay')}
@@ -167,11 +169,11 @@
{#if isSelected($selectedOverlayTree, selectedOverlay)} {#if isSelected($selectedOverlayTree, selectedOverlay)}
{#if $isLayerFromExtension(selectedOverlay)} {#if $isLayerFromExtension(selectedOverlay)}
{$getLayerName(selectedOverlay)} {$getLayerName(selectedOverlay)}
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
{$customLayers[selectedOverlay].name}
{:else} {:else}
{i18n._(`layers.label.${selectedOverlay}`)} {i18n._(`layers.label.${selectedOverlay}`)}
{/if} {/if}
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
{$customLayers[selectedOverlay].name}
{/if} {/if}
{/if} {/if}
</Select.Trigger> </Select.Trigger>
@@ -213,7 +215,9 @@
isSelected($currentOverlays, selectedOverlay) isSelected($currentOverlays, selectedOverlay)
) { ) {
try { try {
$map.removeImport(selectedOverlay); if ($map.getLayer(selectedOverlay)) {
$map.removeLayer(selectedOverlay);
}
} 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
} }
@@ -229,10 +233,10 @@
<Accordion.Item value="custom-layers"> <Accordion.Item value="custom-layers">
<Accordion.Trigger>{i18n._('layers.custom_layers.title')}</Accordion.Trigger <Accordion.Trigger>{i18n._('layers.custom_layers.title')}</Accordion.Trigger
> >
<Accordion.Content> <Accordion.Content
<ScrollArea> class="flex flex-col overflow-visible border rounded-md p-0 mb-1.5"
<CustomLayers /> >
</ScrollArea> <CustomLayers />
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
<Accordion.Item value="terrain-source"> <Accordion.Item value="terrain-source">

View File

@@ -103,7 +103,7 @@ export class ExtensionAPI {
if (current && isSelected(current, overlay.id)) { if (current && isSelected(current, overlay.id)) {
show = true; show = true;
try { try {
get(map)?.removeImport(overlay.id); get(map)?.removeLayer(overlay.id);
} 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
} }

View File

@@ -6,7 +6,10 @@ import { overpassQueryData } from '$lib/assets/layers';
import { MapPopup } from '$lib/components/map/map-popup'; import { MapPopup } from '$lib/components/map/map-popup';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { db } from '$lib/db'; import { db } from '$lib/db';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/map'; import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
const { currentOverpassQueries } = settings; const { currentOverpassQueries } = settings;
@@ -25,7 +28,8 @@ export class OverpassLayer {
minZoom = 12; minZoom = 12;
queryZoom = 12; queryZoom = 12;
expirationTime = 7 * 24 * 3600 * 1000; expirationTime = 7 * 24 * 3600 * 1000;
map: mapboxgl.Map; map: maplibregl.Map;
layerEventManager: MapLayerEventManager;
popup: MapPopup; popup: MapPopup;
currentQueries: Set<string> = new Set(); currentQueries: Set<string> = new Set();
@@ -36,8 +40,9 @@ export class OverpassLayer {
updateBinded = this.update.bind(this); updateBinded = this.update.bind(this);
onHoverBinded = this.onHover.bind(this); onHoverBinded = this.onHover.bind(this);
constructor(map: mapboxgl.Map) { constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
this.map = map; this.map = map;
this.layerEventManager = layerEventManager;
this.popup = new MapPopup(map, { this.popup = new MapPopup(map, {
closeButton: false, closeButton: false,
focusAfterOpen: false, focusAfterOpen: false,
@@ -48,7 +53,7 @@ export class OverpassLayer {
add() { add() {
this.map.on('moveend', this.queryIfNeededBinded); this.map.on('moveend', this.queryIfNeededBinded);
this.map.on('style.import.load', this.updateBinded); this.map.on('style.load', this.updateBinded);
this.unsubscribes.push(data.subscribe(this.updateBinded)); this.unsubscribes.push(data.subscribe(this.updateBinded));
this.unsubscribes.push( this.unsubscribes.push(
currentOverpassQueries.subscribe(() => { currentOverpassQueries.subscribe(() => {
@@ -72,10 +77,17 @@ export class OverpassLayer {
update() { update() {
this.loadIcons(); this.loadIcons();
let d = get(data); const fullData = get(data);
const queries = getCurrentQueries();
const d: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: fullData.features.filter((feature) =>
queries.includes(feature.properties!.query)
),
};
try { try {
let source = this.map.getSource('overpass') as mapboxgl.GeoJSONSource | undefined; let source = this.map.getSource('overpass') as GeoJSONSource | undefined;
if (source) { if (source) {
source.setData(d); source.setData(d);
} else { } else {
@@ -101,13 +113,9 @@ export class OverpassLayer {
ANCHOR_LAYER_KEY.overpass ANCHOR_LAYER_KEY.overpass
); );
this.map.on('mouseenter', 'overpass', this.onHoverBinded); this.layerEventManager.on('mouseenter', 'overpass', this.onHoverBinded);
this.map.on('click', 'overpass', this.onHoverBinded); this.layerEventManager.on('click', 'overpass', this.onHoverBinded);
} }
this.map.setFilter('overpass', ['in', 'query', ...getCurrentQueries()], {
validate: false,
});
} catch (e) { } catch (e) {
// No reliable way to check if the map is ready to add sources and layers // No reliable way to check if the map is ready to add sources and layers
} }
@@ -115,7 +123,9 @@ export class OverpassLayer {
remove() { remove() {
this.map.off('moveend', this.queryIfNeededBinded); this.map.off('moveend', this.queryIfNeededBinded);
this.map.off('style.import.load', this.updateBinded); this.map.off('style.load', this.updateBinded);
this.layerEventManager.off('mouseenter', 'overpass', this.onHoverBinded);
this.layerEventManager.off('click', 'overpass', this.onHoverBinded);
this.unsubscribes.forEach((unsubscribe) => unsubscribe()); this.unsubscribes.forEach((unsubscribe) => unsubscribe());
try { try {
@@ -248,27 +258,16 @@ export class OverpassLayer {
loadIcons() { loadIcons() {
let currentQueries = getCurrentQueries(); let currentQueries = getCurrentQueries();
currentQueries.forEach((query) => { currentQueries.forEach((query) => {
if (!this.map.hasImage(`overpass-${query}`)) { loadSVGIcon(
let icon = new Image(100, 100); this.map,
icon.onload = () => { `overpass-${query}`,
if (!this.map.hasImage(`overpass-${query}`)) { `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
this.map.addImage(`overpass-${query}`, icon);
}
};
// Lucide icons are SVG files with a 24x24 viewBox
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
icon.src =
'data:image/svg+xml,' +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" /> <circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
<g transform="translate(8 8)"> <g transform="translate(8 8)">
${overpassQueryData[query].icon.svg.replace('stroke="currentColor"', 'stroke="white"')} ${overpassQueryData[query].icon.svg.replace('stroke="currentColor"', 'stroke="white"')}
</g> </g>
</svg> </svg>`
`); );
}
}); });
} }
} }

View File

@@ -1,5 +1,4 @@
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 (
@@ -76,5 +75,3 @@ export function removeAll(node: LayerTreeType, ids: string[]) {
}); });
return node; return node;
} }
export const customBasemapUpdate = writable(0);

View File

@@ -0,0 +1,281 @@
import { fileStateCollection } from '$lib/logic/file-state';
import maplibregl from 'maplibre-gl';
type MapLayerMouseEventListener = (e: maplibregl.MapLayerMouseEvent) => void;
type MapLayerTouchEventListener = (e: maplibregl.MapLayerTouchEvent) => void;
type MapLayerListener = {
features: maplibregl.MapGeoJSONFeature[];
mousemoves: MapLayerMouseEventListener[];
mouseenters: MapLayerMouseEventListener[];
mouseleaves: MapLayerMouseEventListener[];
mousedowns: MapLayerMouseEventListener[];
clicks: MapLayerMouseEventListener[];
contextmenus: MapLayerMouseEventListener[];
touchstarts: MapLayerTouchEventListener[];
};
export class MapLayerEventManager {
private _map: maplibregl.Map;
private _listeners: Record<string, MapLayerListener> = {};
constructor(map: maplibregl.Map) {
this._map = map;
this._map.on('mousemove', this._handleMouseMove.bind(this));
this._map.on('click', this._handleMouseClick.bind(this, 'click'));
this._map.on('contextmenu', this._handleMouseClick.bind(this, 'contextmenu'));
this._map.on('mousedown', this._handleMouseClick.bind(this, 'mousedown'));
this._map.on('touchstart', this._handleTouchStart.bind(this));
}
on(
eventType:
| 'mousemove'
| 'mouseenter'
| 'mouseleave'
| 'mousedown'
| 'click'
| 'contextmenu'
| 'touchstart',
layerId: string,
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
) {
if (!this._listeners[layerId]) {
this._listeners[layerId] = {
features: [],
mousemoves: [],
mouseenters: [],
mouseleaves: [],
mousedowns: [],
clicks: [],
contextmenus: [],
touchstarts: [],
};
}
switch (eventType) {
case 'mousemove':
this._listeners[layerId].mousemoves.push(listener as MapLayerMouseEventListener);
break;
case 'mouseenter':
this._listeners[layerId].mouseenters.push(listener as MapLayerMouseEventListener);
break;
case 'mouseleave':
this._listeners[layerId].mouseleaves.push(listener as MapLayerMouseEventListener);
break;
case 'mousedown':
this._listeners[layerId].mousedowns.push(listener as MapLayerMouseEventListener);
break;
case 'click':
this._listeners[layerId].clicks.push(listener as MapLayerMouseEventListener);
break;
case 'contextmenu':
this._listeners[layerId].contextmenus.push(listener as MapLayerMouseEventListener);
break;
case 'touchstart':
this._listeners[layerId].touchstarts.push(listener as MapLayerTouchEventListener);
break;
}
}
off(
eventType:
| 'mousemove'
| 'mouseenter'
| 'mouseleave'
| 'mousedown'
| 'click'
| 'contextmenu'
| 'touchstart',
layerId: string,
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
) {
if (this._listeners[layerId]) {
switch (eventType) {
case 'mousemove':
this._listeners[layerId].mousemoves = this._listeners[
layerId
].mousemoves.filter((l) => l !== listener);
break;
case 'mouseenter':
this._listeners[layerId].mouseenters = this._listeners[
layerId
].mouseenters.filter((l) => l !== listener);
break;
case 'mouseleave':
this._listeners[layerId].mouseleaves = this._listeners[
layerId
].mouseleaves.filter((l) => l !== listener);
break;
case 'mousedown':
this._listeners[layerId].mousedowns = this._listeners[
layerId
].mousedowns.filter((l) => l !== listener);
break;
case 'click':
this._listeners[layerId].clicks = this._listeners[layerId].clicks.filter(
(l) => l !== listener
);
break;
case 'contextmenu':
this._listeners[layerId].contextmenus = this._listeners[
layerId
].contextmenus.filter((l) => l !== listener);
break;
case 'touchstart':
this._listeners[layerId].touchstarts = this._listeners[
layerId
].touchstarts.filter((l) => l !== listener);
break;
}
if (
this._listeners[layerId].mousemoves.length === 0 &&
this._listeners[layerId].mouseenters.length === 0 &&
this._listeners[layerId].mouseleaves.length === 0 &&
this._listeners[layerId].mousedowns.length === 0 &&
this._listeners[layerId].clicks.length === 0 &&
this._listeners[layerId].contextmenus.length === 0 &&
this._listeners[layerId].touchstarts.length === 0
) {
delete this._listeners[layerId];
}
}
}
private _handleMouseMove(e: maplibregl.MapMouseEvent) {
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId];
if ((features.length == 0) != (listener.features.length == 0)) {
if (features.length > 0) {
if (listener.mouseenters.length > 0) {
const event = new maplibregl.MapMouseEvent(
'mouseenter',
e.target,
e.originalEvent,
{
features: featuresByLayer[layerId]!,
}
);
listener.mouseenters.forEach((l) => l(event));
}
} else {
if (listener.mouseleaves.length > 0) {
const event = new maplibregl.MapMouseEvent(
'mouseleave',
e.target,
e.originalEvent
);
listener.mouseleaves.forEach((l) => l(event));
}
}
}
if (features.length > 0 && listener.mousemoves.length > 0) {
const event = new maplibregl.MapMouseEvent('mousemove', e.target, e.originalEvent, {
features: featuresByLayer[layerId]!,
});
listener.mousemoves.forEach((l) => l(event));
}
listener.features = features;
});
}
private _handleMouseClick(type: string, e: maplibregl.MapMouseEvent) {
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId];
if (features.length > 0) {
if (type === 'click' && listener.clicks.length > 0) {
const event = new maplibregl.MapMouseEvent('click', e.target, e.originalEvent, {
features: features,
});
listener.clicks.forEach((l) => l(event));
} else if (type === 'contextmenu' && listener.contextmenus.length > 0) {
const event = new maplibregl.MapMouseEvent(
'contextmenu',
e.target,
e.originalEvent,
{
features: features,
}
);
listener.contextmenus.forEach((l) => l(event));
} else if (type === 'mousedown' && listener.mousedowns.length > 0) {
const event = new maplibregl.MapMouseEvent(
'mousedown',
e.target,
e.originalEvent,
{
features: features,
}
);
listener.mousedowns.forEach((l) => l(event));
}
}
});
}
private _handleTouchStart(e: maplibregl.MapTouchEvent) {
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId];
if (features.length > 0) {
const event: maplibregl.MapLayerTouchEvent = new maplibregl.MapTouchEvent(
'touchstart',
e.target,
e.originalEvent
);
event.features = featuresByLayer[layerId]!;
listener.touchstarts.forEach((l) => l(event));
}
});
}
private _getBounds(point: maplibregl.Point) {
const delta = 30;
return new maplibregl.LngLatBounds(
this._map.unproject([point.x - delta, point.y + delta]),
this._map.unproject([point.x + delta, point.y - delta])
);
}
private _filterLayersIntersectingBounds(
layerIds: string[],
bounds: maplibregl.LngLatBounds
): string[] {
let result = layerIds.filter((layerId) => {
if (!this._map.getLayer(layerId)) return false;
const fileId = layerId.replace('-waypoints', '');
if (fileId === layerId) {
return fileStateCollection.getStatistics(fileId)?.intersectsBBox(bounds) ?? true;
} else {
return (
fileStateCollection.getStatistics(fileId)?.intersectsWaypointBBox(bounds) ??
true
);
}
});
return result;
}
private _getRenderedFeaturesByLayer(e: maplibregl.MapMouseEvent | maplibregl.MapTouchEvent) {
const layerIds = this._filterLayersIntersectingBounds(
Object.keys(this._listeners),
this._getBounds(e.point)
);
const features =
layerIds.length > 0
? this._map.queryRenderedFeatures(e.point, { layers: layerIds })
: [];
const featuresByLayer: Record<string, maplibregl.MapGeoJSONFeature[]> = {};
features.forEach((f) => {
if (!featuresByLayer[f.layer.id]) {
featuresByLayer[f.layer.id] = [];
}
featuresByLayer[f.layer.id].push(f);
});
return featuresByLayer;
}
}

View File

@@ -1,5 +1,5 @@
import { TrackPoint, Waypoint } from 'gpx'; import { TrackPoint, Waypoint } from 'gpx';
import mapboxgl from 'mapbox-gl'; import maplibregl from 'maplibre-gl';
import { mount, tick, unmount } from 'svelte'; import { mount, tick, unmount } from 'svelte';
import { get, writable, type Writable } from 'svelte/store'; import { get, writable, type Writable } from 'svelte/store';
import MapPopupComponent from '$lib/components/map/MapPopup.svelte'; import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
@@ -11,15 +11,15 @@ export type PopupItem<T = Waypoint | TrackPoint | any> = {
}; };
export class MapPopup { export class MapPopup {
map: mapboxgl.Map; map: maplibregl.Map;
popup: mapboxgl.Popup; popup: maplibregl.Popup;
item: Writable<PopupItem | null> = writable(null); item: Writable<PopupItem | null> = writable(null);
component: ReturnType<typeof mount>; component: ReturnType<typeof mount>;
maybeHideBinded = this.maybeHide.bind(this); maybeHideBinded = this.maybeHide.bind(this);
constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) { constructor(map: maplibregl.Map, options?: maplibregl.PopupOptions) {
this.map = map; this.map = map;
this.popup = new mapboxgl.Popup(options); this.popup = new maplibregl.Popup(options);
this.component = mount(MapPopupComponent, { this.component = mount(MapPopupComponent, {
target: document.body, target: document.body,
props: { props: {
@@ -51,7 +51,7 @@ export class MapPopup {
this.map.on('mousemove', this.maybeHideBinded); this.map.on('mousemove', this.maybeHideBinded);
} }
maybeHide(e: mapboxgl.MapMouseEvent) { maybeHide(e: maplibregl.MapMouseEvent) {
const item = get(this.item); const item = get(this.item);
if (item === null) { if (item === null) {
this.hide(); this.hide();
@@ -75,10 +75,10 @@ export class MapPopup {
getCoordinates() { getCoordinates() {
const item = get(this.item); const item = get(this.item);
if (item === null) { if (item === null) {
return new mapboxgl.LngLat(0, 0); return new maplibregl.LngLat(0, 0);
} }
return item.item instanceof Waypoint || item.item instanceof TrackPoint return item.item instanceof Waypoint || item.item instanceof TrackPoint
? item.item.getCoordinates() ? item.item.getCoordinates()
: new mapboxgl.LngLat(item.item.lon, item.item.lat); : new maplibregl.LngLat(item.item.lon, item.item.lat);
} }
} }

View File

@@ -1,110 +1,80 @@
import mapboxgl from 'mapbox-gl'; import maplibregl from 'maplibre-gl';
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder'; import 'maplibre-gl/dist/maplibre-gl.css';
import MaplibreGeocoder, {
type MaplibreGeocoderFeatureResults,
} from '@maplibre/maplibre-gl-geocoder';
import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css';
import { get, writable, type Writable } from 'svelte/store'; import { get, writable, type Writable } from 'svelte/store';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { terrainSources } from '$lib/assets/layers'; import { ANCHOR_LAYER_KEY, StyleManager } from '$lib/components/map/style';
import { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
const { const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings;
treeFileView,
elevationProfile,
bottomPanelSize,
rightPanelSize,
distanceUnits,
terrainSource,
} = settings;
let fitBoundsOptions: mapboxgl.MapOptions['fitBoundsOptions'] = { let fitBoundsOptions: maplibregl.MapOptions['fitBoundsOptions'] = {
maxZoom: 15, maxZoom: 15,
linear: true, linear: true,
easing: () => 1, easing: () => 1,
}; };
const emptySource: mapboxgl.GeoJSONSourceSpecification = { export class MapLibreGLMap {
type: 'geojson', private _maptilerKey: string = '';
data: { private _map: maplibregl.Map | null = null;
type: 'FeatureCollection', private _mapStore: Writable<maplibregl.Map | null> = writable(null);
features: [], private _styleManager: StyleManager | null = null;
}, private _onLoadCallbacks: ((map: maplibregl.Map) => void)[] = [];
};
export const ANCHOR_LAYER_KEY = {
mapillary: 'mapillary-end',
tracks: 'tracks-end',
directionMarkers: 'direction-markers-end',
distanceMarkers: 'distance-markers-end',
interactions: 'interactions-end',
overpass: 'overpass-end',
waypoints: 'waypoints-end',
};
const anchorLayers: mapboxgl.LayerSpecification[] = Object.values(ANCHOR_LAYER_KEY).map((id) => ({
id: id,
type: 'symbol',
source: 'empty-source',
}));
export class MapboxGLMap {
private _map: Writable<mapboxgl.Map | null> = writable(null);
private _onLoadCallbacks: ((map: mapboxgl.Map) => void)[] = [];
private _unsubscribes: (() => void)[] = []; private _unsubscribes: (() => void)[] = [];
private callOnLoadBinded: () => void = this.callOnLoad.bind(this);
public layerEventManager: MapLayerEventManager | null = null;
subscribe(run: (value: mapboxgl.Map | null) => void, invalidate?: () => void) { subscribe(run: (value: maplibregl.Map | null) => void, invalidate?: () => void) {
return this._map.subscribe(run, invalidate); return this._mapStore.subscribe(run, invalidate);
} }
init(language: string, hash: boolean, geocoder: boolean, geolocate: boolean) { init(
const map = new mapboxgl.Map({ maptilerKey: string,
language: string,
hash: boolean,
geocoder: boolean,
geolocate: boolean
) {
this._maptilerKey = maptilerKey;
this._styleManager = new StyleManager(this._mapStore, this._maptilerKey);
const map = new maplibregl.Map({
container: 'map', container: 'map',
style: { style: {
version: 8, version: 8,
sources: { projection: {
'empty-source': emptySource, type: 'globe',
}, },
layers: anchorLayers, sources: {},
imports: [ layers: [],
{
id: 'basemap',
url: '',
},
{
id: 'overlays',
url: '',
},
],
}, },
projection: 'globe',
zoom: 0, zoom: 0,
hash: hash, hash: hash,
language,
attributionControl: false,
logoPosition: 'bottom-right',
boxZoom: false, boxZoom: false,
maxPitch: 90,
}); });
this.layerEventManager = new MapLayerEventManager(map);
map.addControl( map.addControl(
new mapboxgl.AttributionControl({ new maplibregl.NavigationControl({
compact: true,
})
);
map.addControl(
new mapboxgl.NavigationControl({
visualizePitch: true, visualizePitch: true,
}) })
); );
if (geocoder) { if (geocoder) {
let geocoder = new MapboxGeocoder({ let geocoder = new MaplibreGeocoder(
mapboxgl: mapboxgl, {
enableEventLogging: false, forwardGeocode: async (config) => {
collapsed: true, const results: MaplibreGeocoderFeatureResults = {
flyTo: fitBoundsOptions, features: [],
language, type: 'FeatureCollection',
localGeocoder: () => [], };
localGeocoderOnly: true, try {
externalGeocoder: (query: string) => const request = `https://nominatim.openstreetmap.org/search?format=json&q=${config.query}&limit=5&accept-language=${language}`;
fetch( const response = await fetch(request);
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}` const geojson = await response.json();
) results.features = geojson.map((result: any) => {
.then((response) => response.json())
.then((data) => {
return data.map((result: any) => {
return { return {
type: 'Feature', type: 'Feature',
geometry: { geometry: {
@@ -114,61 +84,43 @@ export class MapboxGLMap {
place_name: result.display_name, place_name: result.display_name,
}; };
}); });
}), } catch (e) {}
}); return results;
let onKeyDown = geocoder._onKeyDown; },
geocoder._onKeyDown = (e: KeyboardEvent) => { },
// Trigger search on Enter key only {
if (e.key === 'Enter') { maplibregl: maplibregl,
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]); enableEventLogging: false,
} else if (geocoder._typeahead.data.length > 0) { collapsed: true,
geocoder._typeahead.clear(); flyTo: fitBoundsOptions,
language,
} }
}; );
map.addControl(geocoder); map.addControl(geocoder);
} }
if (geolocate) { if (geolocate) {
map.addControl( map.addControl(
new mapboxgl.GeolocateControl({ new maplibregl.GeolocateControl({
positionOptions: { positionOptions: {
enableHighAccuracy: true, enableHighAccuracy: true,
}, },
fitBoundsOptions, fitBoundsOptions,
trackUserLocation: true, trackUserLocation: true,
showUserHeading: true,
}) })
); );
} }
const scaleControl = new mapboxgl.ScaleControl({ const scaleControl = new maplibregl.ScaleControl({
unit: get(distanceUnits), unit: get(distanceUnits),
}); });
map.addControl(scaleControl); map.addControl(scaleControl);
map.on('style.load', () => {
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', this.setTerrain.bind(this));
this.setTerrain();
});
map.on('style.import.load', () => {
const basemap = map.getStyle().imports?.find((imprt) => imprt.id === 'basemap');
if (basemap && basemap.data && basemap.data.glyphs) {
map.setGlyphsUrl(basemap.data.glyphs);
}
});
map.on('load', () => { map.on('load', () => {
this._map.set(map); // only set the store after the map has loaded this._map = map;
this._mapStore.set(map); // only set the store after the map has loaded
window._map = map; // entry point for extensions window._map = map; // entry point for extensions
this.resize(); this.resize();
this.setTerrain();
scaleControl.setUnit(get(distanceUnits)); scaleControl.setUnit(get(distanceUnits));
this._onLoadCallbacks.forEach((callback) => callback(map));
this._onLoadCallbacks = [];
}); });
map.on('style.load', this.callOnLoadBinded);
this._unsubscribes.push(treeFileView.subscribe(() => this.resize())); this._unsubscribes.push(treeFileView.subscribe(() => this.resize()));
this._unsubscribes.push(elevationProfile.subscribe(() => this.resize())); this._unsubscribes.push(elevationProfile.subscribe(() => this.resize()));
@@ -179,70 +131,50 @@ export class MapboxGLMap {
scaleControl.setUnit(units); scaleControl.setUnit(units);
}) })
); );
this._unsubscribes.push(terrainSource.subscribe(() => this.setTerrain()));
}
onLoad(callback: (map: mapboxgl.Map) => void) {
const map = get(this._map);
if (map) {
callback(map);
} else {
this._onLoadCallbacks.push(callback);
}
} }
destroy() { destroy() {
const map = get(this._map); if (this._map) {
if (map) { this._map.remove();
map.remove(); this._mapStore.set(null);
this._map.set(null);
} }
this._unsubscribes.forEach((unsubscribe) => unsubscribe()); this._unsubscribes.forEach((unsubscribe) => unsubscribe());
this._unsubscribes = []; this._unsubscribes = [];
} }
resize() { resize() {
const map = get(this._map); if (this._map) {
if (map) {
tick().then(() => { tick().then(() => {
map.resize(); this._map?.resize();
}); });
} }
} }
toggle3D() { toggle3D() {
const map = get(this._map); if (this._map) {
if (map) { if (this._map.getPitch() === 0) {
if (map.getPitch() === 0) { this._map.easeTo({ pitch: 70 });
map.easeTo({ pitch: 70 });
} else { } else {
map.easeTo({ pitch: 0 }); this._map.easeTo({ pitch: 0 });
} }
} }
} }
setTerrain() { onLoad(callback: (map: maplibregl.Map) => void) {
const map = get(this._map); if (this._map) {
if (map) { callback(this._map);
const source = get(terrainSource); } else {
try { this._onLoadCallbacks.push(callback);
if (!map.getSource(source)) { }
map.addSource(source, terrainSources[source]); }
}
if (map.getPitch() > 0) { callOnLoad() {
map.setTerrain({ if (this._map && this._map.getLayer(ANCHOR_LAYER_KEY.overlays)) {
source: source, this._onLoadCallbacks.forEach((callback) => callback(this._map!));
exaggeration: 1, this._onLoadCallbacks = [];
}); this._map.off('style.load', this.callOnLoadBinded);
} else {
map.setTerrain(null);
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
return;
}
} }
} }
} }
export const map = new MapboxGLMap(); export const map = new MapLibreGLMap();

View File

@@ -20,9 +20,14 @@
let container: HTMLElement; let container: HTMLElement;
onMount(() => { onMount(() => {
map.onLoad((map: mapboxgl.Map) => { map.onLoad((map_: maplibregl.Map) => {
googleRedirect = new GoogleRedirect(map); googleRedirect = new GoogleRedirect(map_);
mapillaryLayer = new MapillaryLayer(map, container, mapillaryOpen); mapillaryLayer = new MapillaryLayer(
map_,
map.layerEventManager!,
container,
mapillaryOpen
);
}); });
}); });
@@ -48,7 +53,7 @@
<CustomControl class="w-[29px] h-[29px] shrink-0"> <CustomControl class="w-[29px] h-[29px] shrink-0">
<ButtonWithTooltip <ButtonWithTooltip
variant="ghost" variant="ghost"
class="w-full h-full" class="w-full h-full border-none rounded-sm"
side="left" side="left"
label={i18n._('menu.toggle_street_view')} label={i18n._('menu.toggle_street_view')}
onclick={() => { onclick={() => {

View File

@@ -1,11 +1,10 @@
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import type mapboxgl from 'mapbox-gl';
export class GoogleRedirect { export class GoogleRedirect {
map: mapboxgl.Map; map: maplibregl.Map;
enabled = false; enabled = false;
constructor(map: mapboxgl.Map) { constructor(map: maplibregl.Map) {
this.map = map; this.map = map;
} }
@@ -25,7 +24,7 @@ export class GoogleRedirect {
this.map.off('click', this.openStreetView); this.map.off('click', this.openStreetView);
} }
openStreetView(e: mapboxgl.MapMouseEvent) { openStreetView(e: maplibregl.MapMouseEvent) {
window.open( window.open(
`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${e.lngLat.lat},${e.lngLat.lng}` `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${e.lngLat.lat},${e.lngLat.lng}`
); );

View File

@@ -1,8 +1,9 @@
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from 'mapbox-gl'; import maplibregl, { type LayerSpecification, type VectorSourceSpecification } from 'maplibre-gl';
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 { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/map'; import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
const mapillarySource: VectorSourceSpecification = { const mapillarySource: VectorSourceSpecification = {
type: 'vector', type: 'vector',
@@ -42,8 +43,9 @@ const mapillaryImageLayer: LayerSpecification = {
}; };
export class MapillaryLayer { export class MapillaryLayer {
map: mapboxgl.Map; map: maplibregl.Map;
marker: mapboxgl.Marker; layerEventManager: MapLayerEventManager;
marker: maplibregl.Marker;
viewer: Viewer; viewer: Viewer;
active = false; active = false;
@@ -53,8 +55,14 @@ export class MapillaryLayer {
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: maplibregl.Map,
layerEventManager: MapLayerEventManager,
container: HTMLElement,
popupOpen: { value: boolean }
) {
this.map = map; this.map = map;
this.layerEventManager = layerEventManager;
this.viewer = new Viewer({ this.viewer = new Viewer({
accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011', accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011',
@@ -62,15 +70,12 @@ export class MapillaryLayer {
}); });
const element = document.createElement('div'); const element = document.createElement('div');
element.className = 'mapboxgl-user-location mapboxgl-user-location-show-heading'; element.className = 'maplibregl-user-location maplibregl-user-location-show-heading';
const dot = document.createElement('div'); const dot = document.createElement('div');
dot.className = 'mapboxgl-user-location-dot'; dot.className = 'maplibregl-user-location-dot';
const heading = document.createElement('div');
heading.className = 'mapboxgl-user-location-heading';
element.appendChild(dot); element.appendChild(dot);
element.appendChild(heading);
this.marker = new mapboxgl.Marker({ this.marker = new maplibregl.Marker({
rotationAlignment: 'map', rotationAlignment: 'map',
element, element,
}); });
@@ -106,14 +111,14 @@ export class MapillaryLayer {
this.map.addLayer(mapillaryImageLayer, ANCHOR_LAYER_KEY.mapillary); this.map.addLayer(mapillaryImageLayer, ANCHOR_LAYER_KEY.mapillary);
} }
this.map.on('style.load', this.addBinded); this.map.on('style.load', this.addBinded);
this.map.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded); this.layerEventManager.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
this.map.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded); this.layerEventManager.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
} }
remove() { remove() {
this.map.off('style.load', this.addBinded); this.map.off('style.load', this.addBinded);
this.map.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded); this.layerEventManager.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
this.map.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded); this.layerEventManager.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
if (this.map.getLayer('mapillary-image')) { if (this.map.getLayer('mapillary-image')) {
this.map.removeLayer('mapillary-image'); this.map.removeLayer('mapillary-image');
@@ -135,7 +140,7 @@ export class MapillaryLayer {
this.popupOpen.value = false; this.popupOpen.value = false;
} }
onMouseEnter(e: mapboxgl.MapMouseEvent) { onMouseEnter(e: maplibregl.MapLayerMouseEvent) {
if ( if (
e.features && e.features &&
e.features.length > 0 && e.features.length > 0 &&

View File

@@ -0,0 +1,234 @@
import { settings } from '$lib/logic/settings';
import { get, type Writable } from 'svelte/store';
import {
basemaps,
defaultBasemap,
maptilerKeyPlaceHolder,
overlays,
terrainSources,
} from '$lib/assets/layers';
import { getLayers } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n.svelte';
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource } = settings;
const emptySource: maplibregl.GeoJSONSourceSpecification = {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [],
},
};
export const ANCHOR_LAYER_KEY = {
overlays: 'overlays-end',
mapillary: 'mapillary-end',
tracks: 'tracks-end',
directionMarkers: 'direction-markers-end',
distanceMarkers: 'distance-markers-end',
startEndMarkers: 'start-end-markers-end',
interactions: 'interactions-end',
overpass: 'overpass-end',
waypoints: 'waypoints-end',
routingControls: 'routing-controls-end',
};
const anchorLayers: maplibregl.LayerSpecification[] = Object.values(ANCHOR_LAYER_KEY).map((id) => ({
id: id,
type: 'symbol',
source: 'empty-source',
}));
export class StyleManager {
private _map: Writable<maplibregl.Map | null>;
private _maptilerKey: string;
private _pastOverlays: Set<string> = new Set();
constructor(map: Writable<maplibregl.Map | null>, maptilerKey: string) {
this._map = map;
this._maptilerKey = maptilerKey;
this._map.subscribe((map_) => {
if (map_) {
this.updateBasemap();
map_.on('style.load', () => this.updateOverlays());
map_.on('pitch', () => this.updateTerrain());
}
});
currentBasemap.subscribe(() => this.updateBasemap());
currentOverlays.subscribe(() => this.updateOverlays());
opacities.subscribe(() => this.updateOverlays());
terrainSource.subscribe(() => this.updateTerrain());
customLayers.subscribe(() => this.updateBasemap());
}
updateBasemap() {
const map_ = get(this._map);
if (!map_) return;
this.buildStyle().then((style) => map_.setStyle(style));
}
async buildStyle(): Promise<maplibregl.StyleSpecification> {
const custom = get(customLayers);
const style: maplibregl.StyleSpecification = {
version: 8,
projection: {
type: 'globe',
},
sources: {
'empty-source': emptySource,
},
layers: [],
};
let basemap = get(currentBasemap);
const basemapInfo = basemaps[basemap] ?? custom[basemap]?.value ?? basemaps[defaultBasemap];
const basemapStyle = await this.get(basemapInfo);
this.merge(style, basemapStyle);
if (this._maptilerKey !== '') {
const terrain = this.getCurrentTerrain();
style.sources[terrain.source] = terrainSources[terrain.source];
style.terrain = terrain.exaggeration > 0 ? terrain : undefined;
}
style.layers.push(...anchorLayers);
return style;
}
async updateOverlays() {
const map_ = get(this._map);
if (!map_) return;
if (!map_.getSource('empty-source')) return;
const custom = get(customLayers);
const overlayOpacities = get(opacities);
try {
const layers = getLayers(get(currentOverlays) ?? {});
for (let overlay in layers) {
if (!layers[overlay]) {
if (this._pastOverlays.has(overlay)) {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
const overlayStyle = await this.get(overlayInfo);
for (let layer of overlayStyle.layers ?? []) {
if (map_.getLayer(layer.id)) {
map_.removeLayer(layer.id);
}
}
this._pastOverlays.delete(overlay);
}
} else {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
const overlayStyle = await this.get(overlayInfo);
const opacity = overlayOpacities[overlay];
for (let sourceId in overlayStyle.sources) {
if (!map_.getSource(sourceId)) {
map_.addSource(sourceId, overlayStyle.sources[sourceId]);
}
}
for (let layer of overlayStyle.layers ?? []) {
if (!map_.getLayer(layer.id)) {
if (opacity !== undefined) {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = opacity;
} else if (layer.type === 'hillshade') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['hillshade-exaggeration'] = opacity / 2;
}
}
map_.addLayer(layer, ANCHOR_LAYER_KEY.overlays);
}
}
this._pastOverlays.add(overlay);
}
}
} catch (e) {}
}
updateTerrain() {
if (this._maptilerKey === '') return;
const map_ = get(this._map);
if (!map_) return;
const mapTerrain = map_.getTerrain();
const terrain = this.getCurrentTerrain();
if (JSON.stringify(mapTerrain) !== JSON.stringify(terrain)) {
if (terrain.exaggeration > 0) {
if (!map_.getSource(terrain.source)) {
map_.addSource(terrain.source, terrainSources[terrain.source]);
}
map_.setTerrain(terrain);
} else {
map_.setTerrain(null);
}
}
}
async get(
styleInfo: maplibregl.StyleSpecification | string
): Promise<maplibregl.StyleSpecification> {
if (typeof styleInfo === 'string') {
let styleUrl = styleInfo as string;
if (styleUrl.includes(maptilerKeyPlaceHolder)) {
styleUrl = styleUrl.replace(maptilerKeyPlaceHolder, this._maptilerKey);
}
const response = await fetch(styleUrl, { cache: 'force-cache' });
const style = await response.json();
return style;
} else {
return styleInfo;
}
}
merge(style: maplibregl.StyleSpecification, other: maplibregl.StyleSpecification) {
style.sources = { ...style.sources, ...other.sources };
for (let layer of other.layers ?? []) {
if (layer.type === 'symbol' && layer.layout && layer.layout['text-field']) {
const textField = layer.layout['text-field'];
if (
Array.isArray(textField) &&
textField.length >= 2 &&
textField[0] === 'coalesce' &&
Array.isArray(textField[1]) &&
textField[1][0] === 'get' &&
typeof textField[1][1] === 'string' &&
textField[1][1].startsWith('name')
) {
layer.layout['text-field'] = [
'coalesce',
['get', `name:${i18n.lang}`],
['get', 'name'],
];
}
}
style.layers.push(layer);
}
if (other.sprite && !style.sprite) {
style.sprite = other.sprite;
}
if (other.glyphs && !style.glyphs) {
style.glyphs = other.glyphs;
}
}
getCurrentTerrain() {
const terrain = get(terrainSource);
const source = terrainSources[terrain];
if (source.url && source.url.includes(maptilerKeyPlaceHolder)) {
source.url = source.url.replace(maptilerKeyPlaceHolder, this._maptilerKey);
}
const map_ = get(this._map);
return {
source: terrain,
exaggeration: !map_ || map_.getPitch() === 0 ? 0 : 1,
};
}
}

View File

@@ -11,7 +11,7 @@
import Clean from '$lib/components/toolbar/tools/Clean.svelte'; import Clean from '$lib/components/toolbar/tools/Clean.svelte';
import Reduce from '$lib/components/toolbar/tools/reduce/Reduce.svelte'; import Reduce from '$lib/components/toolbar/tools/reduce/Reduce.svelte';
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte'; import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
import mapboxgl from 'mapbox-gl'; import maplibregl from 'maplibre-gl';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
let { let {
@@ -23,11 +23,11 @@
const { minimizeRoutingMenu } = settings; const { minimizeRoutingMenu } = settings;
let popupElement: HTMLDivElement | undefined = $state(undefined); let popupElement: HTMLDivElement | undefined = $state(undefined);
let popup: mapboxgl.Popup | undefined = $derived.by(() => { let popup: maplibregl.Popup | undefined = $derived.by(() => {
if (!popupElement) { if (!popupElement) {
return undefined; return undefined;
} }
let popup = new mapboxgl.Popup({ let popup = new maplibregl.Popup({
closeButton: false, closeButton: false,
maxWidth: undefined, maxWidth: undefined,
}); });

View File

@@ -15,11 +15,12 @@
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { Trash2 } from '@lucide/svelte'; import { Trash2 } from '@lucide/svelte';
import { ANCHOR_LAYER_KEY, map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import type { GeoJSONSource } from 'mapbox-gl'; import type { GeoJSONSource } from 'maplibre-gl';
import { selection } from '$lib/logic/selection'; import { selection } from '$lib/logic/selection';
import { fileActions } from '$lib/logic/file-actions'; import { fileActions } from '$lib/logic/file-actions';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
let props: { let props: {
class?: string; class?: string;
@@ -28,7 +29,7 @@
let cleanType = $state(CleanType.INSIDE); let cleanType = $state(CleanType.INSIDE);
let deleteTrackpoints = $state(true); let deleteTrackpoints = $state(true);
let deleteWaypoints = $state(true); let deleteWaypoints = $state(true);
let rectangleCoordinates: mapboxgl.LngLat[] = $state([]); let rectangleCoordinates: maplibregl.LngLat[] = $state([]);
$effect(() => { $effect(() => {
if ($map) { if ($map) {

View File

@@ -17,7 +17,7 @@
<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 min-h-8 py-1"
disabled={!validSelection} disabled={!validSelection}
onclick={() => fileActions.addElevationToSelection()} onclick={() => fileActions.addElevationToSelection()}
> >

View File

@@ -76,7 +76,7 @@
{/if} {/if}
<Button <Button
variant="outline" variant="outline"
class="whitespace-normal h-fit" class="whitespace-normal h-fit min-h-8 py-1"
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) || disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
(mergeType === MergeType.CONTENTS && !canMergeContents)} (mergeType === MergeType.CONTENTS && !canMergeContents)}
onclick={() => { onclick={() => {

View File

@@ -185,8 +185,8 @@
<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-1.5 justify-center">
<div class="flex flex-col gap-2 grow"> <div class="flex flex-col gap-1 grow">
<Label for="speed" class="flex flex-row"> <Label for="speed" class="flex flex-row">
<Zap size="16" /> <Zap size="16" />
{#if $velocityUnits === 'speed'} {#if $velocityUnits === 'speed'}
@@ -239,7 +239,7 @@
{/if} {/if}
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 grow"> <div class="flex flex-col gap-1 grow">
<Label for="duration" class="flex flex-row"> <Label for="duration" class="flex flex-row">
<Timer size="16" /> <Timer size="16" />
{i18n._('toolbar.time.total_time')} {i18n._('toolbar.time.total_time')}
@@ -253,57 +253,61 @@
/> />
</div> </div>
</div> </div>
<Label class="flex flex-row"> <div class="flex flex-col gap-1">
<CirclePlay size="16" /> <Label class="flex flex-row">
{i18n._('toolbar.time.start')} <CirclePlay size="16" />
</Label> {i18n._('toolbar.time.start')}
<div class="flex flex-row gap-2"> </Label>
<DatePicker <div class="flex flex-row gap-1.5">
bind:value={startDate} <DatePicker
disabled={!canUpdate} bind:value={startDate}
locale={i18n.lang} disabled={!canUpdate}
placeholder={i18n._('toolbar.time.pick_date')} locale={i18n.lang}
class="w-fit grow" placeholder={i18n._('toolbar.time.pick_date')}
onchange={() => { class="w-fit grow"
untrack(() => updateEnd()); onchange={() => {
}} untrack(() => updateEnd());
/> }}
<Input />
type="time" <Input
step={1} type="time"
disabled={!canUpdate} step={1}
bind:value={startTime} disabled={!canUpdate}
class="w-fit" bind:value={startTime}
onchange={() => { class="w-fit"
untrack(() => updateEnd()); onchange={() => {
}} untrack(() => updateEnd());
/> }}
/>
</div>
</div> </div>
<Label class="flex flex-row"> <div class="flex flex-col gap-1">
<CircleStop size="16" /> <Label class="flex flex-row">
{i18n._('toolbar.time.end')} <CircleStop size="16" />
</Label> {i18n._('toolbar.time.end')}
<div class="flex flex-row gap-2"> </Label>
<DatePicker <div class="flex flex-row gap-1.5">
bind:value={endDate} <DatePicker
disabled={!canUpdate} bind:value={endDate}
locale={i18n.lang} disabled={!canUpdate}
placeholder={i18n._('toolbar.time.pick_date')} locale={i18n.lang}
class="w-fit grow" placeholder={i18n._('toolbar.time.pick_date')}
onchange={() => { class="w-fit grow"
untrack(() => updateStart()); onchange={() => {
}} untrack(() => updateStart());
/> }}
<Input />
type="time" <Input
step={1} type="time"
disabled={!canUpdate} step={1}
bind:value={endTime} disabled={!canUpdate}
class="w-fit" bind:value={endTime}
onchange={() => { class="w-fit"
untrack(() => updateStart()); onchange={() => {
}} untrack(() => updateStart());
/> }}
/>
</div>
</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">
@@ -314,11 +318,11 @@
</div> </div>
{/if} {/if}
</fieldset> </fieldset>
<div class="flex flex-row gap-2 items-center"> <div class="flex flex-row gap-1.5 items-center">
<Button <Button
variant="outline" variant="outline"
disabled={!canUpdate} disabled={!canUpdate}
class="grow whitespace-normal h-fit" class="grow shrink whitespace-normal h-fit min-h-8 py-1"
onclick={() => { onclick={() => {
let effectiveSpeed = getSpeed(); let effectiveSpeed = getSpeed();
if ( if (

View File

@@ -14,7 +14,7 @@
let props: { class?: string } = $props(); let props: { class?: string } = $props();
let sliderValue = $state([50]); let sliderValue = $state(50);
const maxTolerance = 10000; const maxTolerance = 10000;
let validSelection = $derived( let validSelection = $derived(
@@ -25,7 +25,7 @@
$effect(() => { $effect(() => {
tolerance.set( tolerance.set(
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance))) minTolerance * 2 ** (sliderValue / (100 / Math.log2(maxTolerance / minTolerance)))
); );
}); });
@@ -36,7 +36,7 @@
<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} type="single" />
</div> </div>
<Label class="flex flex-row justify-between"> <Label class="flex flex-row justify-between">
<span>{i18n._('toolbar.reduce.tolerance')}</span> <span>{i18n._('toolbar.reduce.tolerance')}</span>

View File

@@ -1,10 +1,11 @@
import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list'; import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list';
import { ANCHOR_LAYER_KEY, map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { fileActions } from '$lib/logic/file-actions'; import { fileActions } from '$lib/logic/file-actions';
import { GPXFileStateCollectionObserver, type GPXFileState } from '$lib/logic/file-state'; import { GPXFileStateCollectionObserver, type GPXFileState } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection'; import { selection } from '$lib/logic/selection';
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx'; import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
import type { GeoJSONSource } from 'mapbox-gl'; import type { GeoJSONSource } from 'maplibre-gl';
import { get, writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
export const minTolerance = 0.1; export const minTolerance = 0.1;

View File

@@ -21,7 +21,7 @@
SquareArrowUpLeft, SquareArrowUpLeft,
SquareArrowOutDownRight, SquareArrowOutDownRight,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { brouterProfiles } from '$lib/components/toolbar/tools/routing/routing'; import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { import {
@@ -51,7 +51,7 @@
}: { }: {
minimized?: boolean; minimized?: boolean;
minimizable?: boolean; minimizable?: boolean;
popup?: mapboxgl.Popup; popup?: maplibregl.Popup;
popupElement?: HTMLDivElement; popupElement?: HTMLDivElement;
class?: string; class?: string;
} = $props(); } = $props();
@@ -167,7 +167,7 @@
{i18n._(`toolbar.routing.activities.${$routingProfile}`)} {i18n._(`toolbar.routing.activities.${$routingProfile}`)}
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
{#each Object.keys(brouterProfiles) as profile} {#each Object.keys(routingProfiles) as profile}
<Select.Item value={profile} <Select.Item value={profile}
>{i18n._( >{i18n._(
`toolbar.routing.activities.${profile}` `toolbar.routing.activities.${profile}`
@@ -191,7 +191,7 @@
<ButtonWithTooltip <ButtonWithTooltip
label={i18n._('toolbar.routing.reverse.tooltip')} label={i18n._('toolbar.routing.reverse.tooltip')}
variant="outline" variant="outline"
class="gap-1 text-xs" class="gap-1 text-xs px-1.5 py-1.5 h-fit"
disabled={!validSelection} disabled={!validSelection}
onclick={fileActions.reverseSelection} onclick={fileActions.reverseSelection}
> >
@@ -200,7 +200,7 @@
<ButtonWithTooltip <ButtonWithTooltip
label={i18n._('toolbar.routing.route_back_to_start.tooltip')} label={i18n._('toolbar.routing.route_back_to_start.tooltip')}
variant="outline" variant="outline"
class="gap-1 text-xs" class="gap-1 text-xs px-1.5 py-1.5 h-fit"
disabled={!validSelection} disabled={!validSelection}
onclick={() => { onclick={() => {
const selected = selection.getOrderedSelection(); const selected = selection.getOrderedSelection();
@@ -236,14 +236,14 @@
<ButtonWithTooltip <ButtonWithTooltip
label={i18n._('toolbar.routing.round_trip.tooltip')} label={i18n._('toolbar.routing.round_trip.tooltip')}
variant="outline" variant="outline"
class="gap-1 text-xs" class="gap-1 text-xs px-1.5 py-1.5 h-fit"
disabled={!validSelection} disabled={!validSelection}
onclick={fileActions.createRoundTripForSelection} onclick={fileActions.createRoundTripForSelection}
> >
<Repeat class="size-3" />{i18n._('toolbar.routing.round_trip.button')} <Repeat class="size-3" />{i18n._('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-1 items-end justify-between">
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/routing')}> <Help link={getURLForLanguage(i18n.lang, '/help/toolbar/routing')}>
{#if !validSelection} {#if !validSelection}
{i18n._('toolbar.routing.help_no_file')} {i18n._('toolbar.routing.help_no_file')}

View File

@@ -6,37 +6,213 @@ import { get } from 'svelte/store';
const { routing, routingProfile, privateRoads } = settings; const { routing, routingProfile, privateRoads } = settings;
export const brouterProfiles: { [key: string]: string } = { export type RoutingProfile = {
bike: 'Trekking-dry', engine: 'graphhopper' | 'brouter';
racing_bike: 'fastbike', profile: string;
gravel_bike: 'gravel', };
mountain_bike: 'MTB',
foot: 'Hiking-Alpine-SAC6', export const routingProfiles: { [key: string]: RoutingProfile } = {
motorcycle: 'Car-FastEco', bike: { engine: 'graphhopper', profile: 'bike' },
water: 'river', racing_bike: { engine: 'graphhopper', profile: 'racingbike' },
railway: 'rail', gravel_bike: { engine: 'graphhopper', profile: 'gravelbike' },
mountain_bike: { engine: 'graphhopper', profile: 'mtb' },
foot: { engine: 'graphhopper', profile: 'foot' },
motorcycle: { engine: 'graphhopper', profile: 'motorbike' },
water: { engine: 'brouter', profile: 'river' },
railway: { engine: 'brouter', profile: 'rail' },
}; };
export function route(points: Coordinates[]): Promise<TrackPoint[]> { export function route(points: Coordinates[]): Promise<TrackPoint[]> {
if (get(routing)) { if (get(routing)) {
return getRoute(points, brouterProfiles[get(routingProfile)], get(privateRoads)); const profile = routingProfiles[get(routingProfile)];
if (profile.engine === 'graphhopper') {
return getGraphHopperRoute(points, profile.profile, get(privateRoads));
} else {
return getBRouterRoute(points, profile.profile);
}
} else { } else {
return getIntermediatePoints(points); return getIntermediatePoints(points);
} }
} }
async function getRoute( const graphhopperDetails = ['road_class', 'surface', 'hike_rating', 'mtb_rating'];
const hikeRatingToSACScale: { [key: string]: string } = {
'1': 'hiking',
'2': 'mountain_hiking',
'3': 'demanding_mountain_hiking',
'4': 'alpine_hiking',
'5': 'demanding_alpine_hiking',
'6': 'difficult_alpine_hiking',
};
const mtbRatingToScale: { [key: string]: string } = {
'1': '0',
'2': '1',
'3': '2',
'4': '3',
'5': '4',
'6': '5',
'7': '6',
};
const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
bike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
racingbike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
gravelbike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
mtb: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
foot: {
priority: [
{
if: 'foot_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
motorcycle: {
priority: [
{
if: 'road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
};
async function getGraphHopperRoute(
points: Coordinates[], points: Coordinates[],
brouterProfile: string, graphHopperProfile: string,
privateRoads: boolean privateRoads: boolean
): Promise<TrackPoint[]> { ): Promise<TrackPoint[]> {
let url = `https://brouter.gpx.studio?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`; let response = await fetch('https://graphhopper.gpx.studio/route', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
points: points.map((point) => [point.lon, point.lat]),
profile: graphHopperProfile,
elevation: true,
points_encoded: false,
details: graphhopperDetails,
custom_model: privateRoads
? {}
: graphhopperBlockPrivateCustomModels[graphHopperProfile] || {},
}),
});
if (!response.ok) {
const error = await response.json();
if (error.message.includes('Cannot find point 0')) {
throw new Error('toolbar.routing.error.from');
} else if (error.message.includes('Cannot find point 1')) {
if (points.length == 3) {
throw new Error('toolbar.routing.error.via');
} else {
throw new Error('toolbar.routing.error.to');
}
} else if (error.hints[0].details.includes('PointDistanceExceededException')) {
throw new Error('toolbar.routing.error.distance');
} else if (error.hints[0].details.includes('ConnectionNotFoundException')) {
throw new Error('toolbar.routing.error.connection');
} else {
throw new Error(error.message);
}
}
let json = await response.json();
let route: TrackPoint[] = [];
let coordinates = json.paths[0].points.coordinates;
let details = json.paths[0].details;
for (let i = 0; i < coordinates.length; i++) {
route.push(
new TrackPoint({
attributes: {
lat: coordinates[i][1],
lon: coordinates[i][0],
},
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
extensions: {},
})
);
}
for (let key of graphhopperDetails) {
let detail = details[key];
for (let i = 0; i < detail.length; i++) {
for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1); j++) {
if (detail[i][2] !== undefined && detail[i][2] !== 'missing') {
if (key === 'road_class') {
route[j].setExtension('highway', detail[i][2]);
} else if (key === 'hike_rating') {
const sacScale = hikeRatingToSACScale[detail[i][2]];
if (sacScale) {
route[j].setExtension('sac_scale', sacScale);
}
} else if (key === 'mtb_rating') {
const mtbScale = mtbRatingToScale[detail[i][2]];
if (mtbScale) {
route[j].setExtension('mtb_scale', mtbScale);
}
} else if (key === 'surface' && detail[i][2] !== 'other') {
route[j].setExtension('surface', detail[i][2]);
}
}
}
}
}
return route;
}
async function getBRouterRoute(
points: Coordinates[],
brouterProfile: string
): Promise<TrackPoint[]> {
let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
let response = await fetch(url); let response = await fetch(url);
// Check if the response is ok
if (!response.ok) { if (!response.ok) {
throw new Error(`${await response.text()}`); const error = await response.text();
if (error.includes('from-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.from');
} else if (error.includes('via1-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.via');
} else if (error.includes('to-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.to');
} else if (error.includes('Time-out')) {
throw new Error('toolbar.routing.error.timeout');
} else {
throw new Error(error);
}
} }
let geojson = await response.json(); let geojson = await response.json();
@@ -52,14 +228,13 @@ async function getRoute(
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {}; let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
for (let i = 0; i < coordinates.length; i++) { for (let i = 0; i < coordinates.length; i++) {
let coord = coordinates[i];
route.push( route.push(
new TrackPoint({ new TrackPoint({
attributes: { attributes: {
lat: coord[1], lat: coordinates[i][1],
lon: coord[0], lon: coordinates[i][0],
}, },
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0), ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
}) })
); );

View File

@@ -2,15 +2,21 @@ import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
const earthRadius = 6371008.8; const earthRadius = 6371008.8;
export const MIN_ANCHOR_ZOOM = 0;
export const MAX_ANCHOR_ZOOM = 22;
export function getZoomLevelForDistance(latitude: number, distance?: number): number { export function getZoomLevelForDistance(latitude: number, distance?: number): number {
if (distance === undefined) { if (distance === undefined) {
return 0; return MIN_ANCHOR_ZOOM;
} }
const rad = Math.PI / 180; const rad = Math.PI / 180;
const lat = latitude * rad; const lat = latitude * rad;
return Math.min(22, Math.max(0, Math.log2((earthRadius * Math.cos(lat)) / distance))); return Math.min(
MAX_ANCHOR_ZOOM,
Math.max(MIN_ANCHOR_ZOOM, Math.round(Math.log2((earthRadius * Math.cos(lat)) / distance)))
);
} }
export function updateAnchorPoints(file: GPXFile) { export function updateAnchorPoints(file: GPXFile) {

View File

@@ -36,7 +36,7 @@
onMount(() => { onMount(() => {
if ($map) { if ($map) {
splitControls = new SplitControls($map); splitControls = new SplitControls($map, map.layerEventManager!);
} }
}); });

View File

@@ -8,40 +8,33 @@ import { get } from 'svelte/store';
import { fileStateCollection } from '$lib/logic/file-state'; import { fileStateCollection } from '$lib/logic/file-state';
import { fileActions } from '$lib/logic/file-actions'; import { fileActions } from '$lib/logic/file-actions';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/map'; import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
export class SplitControls { export class SplitControls {
map: mapboxgl.Map; map: maplibregl.Map;
layerEventManager: MapLayerEventManager;
unsubscribes: Function[] = []; unsubscribes: Function[] = [];
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this); layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this); layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this); layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
constructor(map: mapboxgl.Map) { constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
this.map = map; this.map = map;
this.layerEventManager = layerEventManager;
if (!this.map.hasImage('split-control')) { loadSVGIcon(
let icon = new Image(100, 100); this.map,
icon.onload = () => { 'split-control',
if (!this.map.hasImage('split-control')) { `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
this.map.addImage('split-control', icon); <circle cx="20" cy="20" r="20" fill="white" />
} <g transform="translate(8 8)">
}; ${Scissors.replace('stroke="currentColor"', 'stroke="black"')}
</g>
// Lucide icons are SVG files with a 24x24 viewBox </svg>`
// Create a new SVG with a 32x32 viewBox and center the icon in a circle );
icon.src =
'data:image/svg+xml,' +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="white" />
<g transform="translate(8 8)">
${Scissors.replace('stroke="currentColor"', 'stroke="black"')}
</g>
</svg>
`);
}
this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this))); this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this))); this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
@@ -98,7 +91,7 @@ export class SplitControls {
}, false); }, false);
try { try {
let source = this.map.getSource('split-controls') as mapboxgl.GeoJSONSource | undefined; let source = this.map.getSource('split-controls') as GeoJSONSource | undefined;
if (source) { if (source) {
source.setData(data); source.setData(data);
} else { } else {
@@ -124,9 +117,17 @@ export class SplitControls {
ANCHOR_LAYER_KEY.interactions ANCHOR_LAYER_KEY.interactions
); );
this.map.on('mouseenter', 'split-controls', this.layerOnMouseEnterBinded); this.layerEventManager.on(
this.map.on('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded); 'mouseenter',
this.map.on('click', 'split-controls', this.layerOnClickBinded); 'split-controls',
this.layerOnMouseEnterBinded
);
this.layerEventManager.on(
'mouseleave',
'split-controls',
this.layerOnMouseLeaveBinded
);
this.layerEventManager.on('click', 'split-controls', this.layerOnClickBinded);
} }
} catch (e) { } catch (e) {
// No reliable way to check if the map is ready to add sources and layers // No reliable way to check if the map is ready to add sources and layers
@@ -134,9 +135,9 @@ export class SplitControls {
} }
remove() { remove() {
this.map.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded); this.layerEventManager.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
this.map.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded); this.layerEventManager.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
this.map.off('click', 'split-controls', this.layerOnClickBinded); this.layerEventManager.off('click', 'split-controls', this.layerOnClickBinded);
try { try {
if (this.map.getLayer('split-controls')) { if (this.map.getLayer('split-controls')) {
@@ -159,7 +160,7 @@ export class SplitControls {
mapCursor.notify(MapCursorState.SPLIT_CONTROL, false); mapCursor.notify(MapCursorState.SPLIT_CONTROL, false);
} }
layerOnClick(e: mapboxgl.MapMouseEvent) { layerOnClick(e: maplibregl.MapLayerMouseEvent) {
let coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates; let coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates;
fileActions.split( fileActions.split(
get(splitAs), get(splitAs),

View File

@@ -16,7 +16,7 @@
import { fileActions } from '$lib/logic/file-actions'; import { fileActions } from '$lib/logic/file-actions';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import mapboxgl from 'mapbox-gl'; import maplibregl from 'maplibre-gl';
import { getSvgForSymbol } from '$lib/components/map/gpx-layer/gpx-layer'; import { getSvgForSymbol } from '$lib/components/map/gpx-layer/gpx-layer';
let props: { let props: {
@@ -41,7 +41,7 @@
}) })
); );
let marker: mapboxgl.Marker | null = null; let marker: maplibregl.Marker | null = null;
function reset() { function reset() {
if ($selectedWaypoint) { if ($selectedWaypoint) {
@@ -125,7 +125,7 @@
let element = document.createElement('div'); let element = document.createElement('div');
element.classList.add('w-8', 'h-8'); element.classList.add('w-8', 'h-8');
element.innerHTML = getSvgForSymbol(symbolKey); element.innerHTML = getSvgForSymbol(symbolKey);
marker = new mapboxgl.Marker({ marker = new maplibregl.Marker({
element, element,
anchor: 'bottom', anchor: 'bottom',
}) })
@@ -161,66 +161,73 @@
</script> </script>
<div class="flex flex-col gap-3 w-full max-w-96 {props.class ?? ''}"> <div class="flex flex-col gap-3 w-full max-w-96 {props.class ?? ''}">
<fieldset class="flex flex-col gap-2"> <fieldset class="flex flex-col gap-1.5">
<Label for="name">{i18n._('menu.metadata.name')}</Label> <div class="flex flex-col gap-1">
<Input <Label for="name">{i18n._('menu.metadata.name')}</Label>
bind:value={name} <Input
id="name" bind:value={name}
class="font-semibold h-8" id="name"
disabled={!canCreate && !$selectedWaypoint} class="font-semibold"
/>
<Label for="description">{i18n._('menu.metadata.description')}</Label>
<Textarea
bind:value={description}
id="description"
disabled={!canCreate && !$selectedWaypoint}
class="min-h-8 h-8 py-1 px-3 text-sm"
/>
<Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label>
<Select.Root bind:value={sym} type="single">
<Select.Trigger
id="symbol"
size="sm"
class="w-full"
disabled={!canCreate && !$selectedWaypoint} disabled={!canCreate && !$selectedWaypoint}
> />
<span class="flex flex-row gap-1.5 items-center"> </div>
{#if symbolKey} <div class="flex flex-col gap-1">
{#if symbols[symbolKey].icon} <Label for="description">{i18n._('menu.metadata.description')}</Label>
{@const Component = symbols[symbolKey].icon} <Textarea
<Component size="14" /> bind:value={description}
{/if} id="description"
{i18n._(`gpx.symbol.${symbolKey}`)} disabled={!canCreate && !$selectedWaypoint}
{:else} class="min-h-8 h-8 py-1 px-3 text-sm"
{sym} />
{/if} </div>
</span> <div class="flex flex-col gap-1">
</Select.Trigger> <Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label>
<Select.Content class="max-h-60 overflow-y-scroll"> <Select.Root bind:value={sym} type="single">
{#each sortedSymbols as [key, symbol]} <Select.Trigger
<Select.Item value={symbol.value}> id="symbol"
<span> class="w-full"
{#if symbol.icon} disabled={!canCreate && !$selectedWaypoint}
{@const Component = symbol.icon} >
<Component size="14" class="inline-block align-sub" /> <span class="flex flex-row gap-1.5 items-center">
{:else} {#if symbolKey}
<span class="w-4 inline-block"></span> {#if symbols[symbolKey].icon}
{@const Component = symbols[symbolKey].icon}
<Component size="14" />
{/if} {/if}
{i18n._(`gpx.symbol.${key}`)} {i18n._(`gpx.symbol.${symbolKey}`)}
</span> {:else}
</Select.Item> {sym}
{/each} {/if}
</Select.Content> </span>
</Select.Root> </Select.Trigger>
<Label for="link">{i18n._('toolbar.waypoint.link')}</Label> <Select.Content class="max-h-60">
<Input {#each sortedSymbols as [key, symbol]}
bind:value={link} <Select.Item value={symbol.value}>
id="link" <span>
class="h-8" {#if symbol.icon}
disabled={!canCreate && !$selectedWaypoint} {@const Component = symbol.icon}
/> <Component size="14" class="inline-block align-sub" />
<div class="flex flex-row gap-2"> {:else}
<div class="grow flex flex-col gap-2"> <span class="w-4 inline-block"></span>
{/if}
{i18n._(`gpx.symbol.${key}`)}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1">
<Label for="link">{i18n._('toolbar.waypoint.link')}</Label>
<Input
bind:value={link}
id="link"
class="h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
</div>
<div class="flex flex-row gap-1.5">
<div class="grow flex flex-col gap-1">
<Label for="latitude">{i18n._('toolbar.waypoint.latitude')}</Label> <Label for="latitude">{i18n._('toolbar.waypoint.latitude')}</Label>
<Input <Input
bind:value={latitude} bind:value={latitude}
@@ -233,7 +240,7 @@
disabled={!canCreate && !$selectedWaypoint} disabled={!canCreate && !$selectedWaypoint}
/> />
</div> </div>
<div class="grow flex flex-col gap-2"> <div class="grow flex flex-col gap-1">
<Label for="longitude">{i18n._('toolbar.waypoint.longitude')}</Label> <Label for="longitude">{i18n._('toolbar.waypoint.longitude')}</Label>
<Input <Input
bind:value={longitude} bind:value={longitude}
@@ -248,11 +255,11 @@
</div> </div>
</div> </div>
</fieldset> </fieldset>
<div class="flex flex-row gap-2 items-center"> <div class="flex flex-row gap-1.5 items-center">
<Button <Button
variant="outline" variant="outline"
disabled={!canCreate && !$selectedWaypoint} disabled={!canCreate && !$selectedWaypoint}
class="grow whitespace-normal h-fit" class="grow shrink h-fit min-h-8 whitespace-normal py-1"
onclick={createOrUpdateWaypoint} onclick={createOrUpdateWaypoint}
> >
{#if $selectedWaypoint} {#if $selectedWaypoint}

View File

@@ -13,10 +13,15 @@
<AccordionPrimitive.Content <AccordionPrimitive.Content
bind:ref bind:ref
data-slot="accordion-content" data-slot="accordion-content"
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm" class="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden"
{...restProps} {...restProps}
> >
<div class={cn("pb-4 pt-0", className)}> <div
class={cn(
"pt-0 pb-2.5 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4",
className
)}
>
{@render children?.()} {@render children?.()}
</div> </div>
</AccordionPrimitive.Content> </AccordionPrimitive.Content>

View File

@@ -12,6 +12,6 @@
<AccordionPrimitive.Item <AccordionPrimitive.Item
bind:ref bind:ref
data-slot="accordion-item" data-slot="accordion-item"
class={cn("border-b last:border-b-0", className)} class={cn("not-last:border-b", className)}
{...restProps} {...restProps}
/> />

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui"; import { Accordion as AccordionPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from "$lib/utils.js";
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -19,14 +20,13 @@
data-slot="accordion-trigger" data-slot="accordion-trigger"
bind:ref bind:ref
class={cn( class={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium outline-none transition-all hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180", "focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-lg py-2.5 text-left text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50",
className className
)} )}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
<ChevronDownIcon <ChevronDownIcon data-slot="accordion-trigger-icon" class="cn-accordion-trigger-icon pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" <ChevronUpIcon data-slot="accordion-trigger-icon" class="cn-accordion-trigger-icon pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
/>
</AccordionPrimitive.Trigger> </AccordionPrimitive.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>

View File

@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui"; import { Accordion as AccordionPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
value = $bindable(), value = $bindable(),
class: className,
...restProps ...restProps
}: AccordionPrimitive.RootProps = $props(); }: AccordionPrimitive.RootProps = $props();
</script> </script>
@@ -12,5 +14,6 @@
bind:ref bind:ref
bind:value={value as never} bind:value={value as never}
data-slot="accordion" data-slot="accordion"
class={cn("cn-accordion flex w-full flex-col", className)}
{...restProps} {...restProps}
/> />

View File

@@ -1,18 +1,27 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js"; import {
buttonVariants,
type ButtonVariant,
type ButtonSize,
} from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
variant = "default",
size = "default",
...restProps ...restProps
}: AlertDialogPrimitive.ActionProps = $props(); }: AlertDialogPrimitive.ActionProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script> </script>
<AlertDialogPrimitive.Action <AlertDialogPrimitive.Action
bind:ref bind:ref
data-slot="alert-dialog-action" data-slot="alert-dialog-action"
class={cn(buttonVariants(), className)} class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-action", className)}
{...restProps} {...restProps}
/> />

View File

@@ -1,18 +1,27 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js"; import {
buttonVariants,
type ButtonVariant,
type ButtonSize,
} from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
variant = "outline",
size = "default",
...restProps ...restProps
}: AlertDialogPrimitive.CancelProps = $props(); }: AlertDialogPrimitive.CancelProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script> </script>
<AlertDialogPrimitive.Cancel <AlertDialogPrimitive.Cancel
bind:ref bind:ref
data-slot="alert-dialog-cancel" data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant: "outline" }), className)} class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-cancel", className)}
{...restProps} {...restProps}
/> />

View File

@@ -1,27 +1,32 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import AlertDialogPortal from "./alert-dialog-portal.svelte";
import AlertDialogOverlay from "./alert-dialog-overlay.svelte"; import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
size = "default",
portalProps, portalProps,
...restProps ...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & { }: WithoutChild<AlertDialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>; size?: "default" | "sm";
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
} = $props(); } = $props();
</script> </script>
<AlertDialogPrimitive.Portal {...portalProps}> <AlertDialogPortal {...portalProps}>
<AlertDialogOverlay /> <AlertDialogOverlay />
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
bind:ref bind:ref
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
data-size={size}
class={cn( class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-popover text-popover-foreground ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className className
)} )}
{...restProps} {...restProps}
/> />
</AlertDialogPrimitive.Portal> </AlertDialogPortal>

View File

@@ -12,6 +12,6 @@
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description
bind:ref bind:ref
data-slot="alert-dialog-description" data-slot="alert-dialog-description"
class={cn("text-muted-foreground text-sm", className)} class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3", className)}
{...restProps} {...restProps}
/> />

View File

@@ -13,7 +13,10 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="alert-dialog-footer" data-slot="alert-dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} class={cn(
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}

View File

@@ -13,7 +13,7 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="alert-dialog-header" data-slot="alert-dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-left", className)} class={cn("grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-media"
class={cn("bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -12,9 +12,6 @@
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
bind:ref bind:ref
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
class={cn( class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps} {...restProps}
/> />

View File

@@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
type $$Props = AlertDialogPrimitive.PortalProps; let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
</script> </script>
<AlertDialogPrimitive.Portal {...$$restProps}> <AlertDialogPrimitive.Portal {...restProps} />
<slot />
</AlertDialogPrimitive.Portal>

View File

@@ -12,6 +12,6 @@
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
bind:ref bind:ref
data-slot="alert-dialog-title" data-slot="alert-dialog-title"
class={cn("text-lg font-semibold", className)} class={cn("text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className)}
{...restProps} {...restProps}
/> />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
</script>
<AlertDialogPrimitive.Root bind:open {...restProps} />

View File

@@ -1,4 +1,5 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import Root from "./alert-dialog.svelte";
import Portal from "./alert-dialog-portal.svelte";
import Trigger from "./alert-dialog-trigger.svelte"; import Trigger from "./alert-dialog-trigger.svelte";
import Title from "./alert-dialog-title.svelte"; import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte"; import Action from "./alert-dialog-action.svelte";
@@ -8,9 +9,7 @@ import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte"; import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte"; import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte"; import Description from "./alert-dialog-description.svelte";
import Media from "./alert-dialog-media.svelte";
const Root = AlertDialogPrimitive.Root;
const Portal = AlertDialogPrimitive.Portal;
export { export {
Root, Root,
@@ -24,6 +23,7 @@ export {
Overlay, Overlay,
Content, Content,
Description, Description,
Media,
// //
Root as AlertDialog, Root as AlertDialog,
Title as AlertDialogTitle, Title as AlertDialogTitle,
@@ -36,4 +36,5 @@ export {
Overlay as AlertDialogOverlay, Overlay as AlertDialogOverlay,
Content as AlertDialogContent, Content as AlertDialogContent,
Description as AlertDialogDescription, Description as AlertDialogDescription,
Media as AlertDialogMedia,
}; };

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-action"
class={cn("absolute top-2 right-2", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -14,7 +14,7 @@
bind:this={ref} bind:this={ref}
data-slot="alert-description" data-slot="alert-description"
class={cn( class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", "text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
className className
)} )}
{...restProps} {...restProps}

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