64 Commits

Author SHA1 Message Date
vcoppe 7d3423f9ad New translations settings.mdx (Greek) 2026-04-02 18:54:06 +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
192 changed files with 3182 additions and 3886 deletions
+1 -1
View File
@@ -1 +1 @@
ko_fi: gpxstudio open_collective: gpxstudio
+1 -1
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
+6 -6
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.
@@ -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
``` ```
@@ -69,9 +69,9 @@ This project has been made possible thanks to the following open source projects
- [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
+6 -5
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'] = {};
} }
Object.entries(extensions).forEach(([key, value]) => {
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value; this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value;
}
setExtensions(extensions: Record<string, string>) {
Object.entries(extensions).forEach(([key, value]) => {
this.setExtension(key, value);
}); });
} }
+1 -1
View File
@@ -1 +1 @@
PUBLIC_MAPBOX_TOKEN=YOUR_MAPBOX_TOKEN PUBLIC_MAPTILER_KEY=YOUR_MAPTILER_KEY
+204 -1027
View File
File diff suppressed because it is too large Load Diff
+3 -6
View File
@@ -23,10 +23,9 @@
"@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",
@@ -62,10 +61,9 @@
"dependencies": { "dependencies": {
"@docsearch/js": "^3.9.0", "@docsearch/js": "^3.9.0",
"@internationalized/date": "^3.8.2", "@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", "clsx": "^2.1.1",
@@ -74,9 +72,8 @@
"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" "tailwind-merge": "^3.3.0"
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

+20 -14
View File
@@ -22,15 +22,19 @@ 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}`,
openStreetMap: { openStreetMap: {
version: 8, version: 8,
sources: { sources: {
@@ -773,8 +777,10 @@ 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,
openStreetMap: true, openStreetMap: true,
openTopoMap: true, openTopoMap: true,
openHikingMap: true, openHikingMap: true,
@@ -907,7 +913,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 +1002,10 @@ 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,
openStreetMap: true, openStreetMap: true,
openTopoMap: true, openTopoMap: true,
openHikingMap: true, openHikingMap: true,
@@ -1136,7 +1144,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 +1463,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 +1473,4 @@ export const terrainSources: { [key: string]: RasterDEMSourceSpecification } = {
}, },
}; };
export const defaultTerrainSource = 'mapbox-dem'; export const defaultTerrainSource = 'maptiler-dem';
@@ -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>
@@ -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}
+8 -6
View File
@@ -1,15 +1,15 @@
<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 <Button
@@ -20,7 +20,10 @@
> >
MIT © 2026 gpx.studio MIT © 2026 gpx.studio
</Button> </Button>
<LanguageSelect class="w-40 mt-3" /> <div class="mt-3 flex flex-row gap-1.5">
<LanguageSelect />
<ModeSwitch />
</div>
</div> </div>
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6"> <div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="flex flex-col items-start gap-1"> <div class="flex flex-col items-start gap-1">
@@ -86,7 +89,7 @@
<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://ko-fi.com/gpxstudio" href="https://opencollective.com/gpxstudio"
target="_blank" target="_blank"
> >
<Heart size="16" /> <Heart size="16" />
@@ -113,5 +116,4 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</footer> </footer>
+15 -12
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,13 +32,16 @@
<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'} border-none shadow-none 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'
? 'flex-col h-full justify-center'
: 'flex-row w-full justify-evenly'} gap-4"
> >
<Tooltip label={i18n._('quantities.distance')}> <Tooltip label={i18n._('quantities.distance')}>
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
@@ -53,9 +57,8 @@
<WithUnits value={statistics.elevation.loss} type="elevation" /> <WithUnits value={statistics.elevation.loss} type="elevation" />
</span> </span>
</Tooltip> </Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'} {#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)}
<Tooltip <Tooltip
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
label="{$velocityUnits === 'speed' label="{$velocityUnits === 'speed'
? i18n._('quantities.speed') ? i18n._('quantities.speed')
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._( : i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
@@ -70,9 +73,8 @@
</span> </span>
</Tooltip> </Tooltip>
{/if} {/if}
{#if panelSize > 160 || orientation === 'horizontal'} {#if panelHeight > 160 || (orientation === 'horizontal' && panelWidth > 620)}
<Tooltip <Tooltip
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._( label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total' 'quantities.total'
)})" )})"
@@ -85,5 +87,6 @@
</span> </span>
</Tooltip> </Tooltip>
{/if} {/if}
</div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
@@ -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]}
+4 -4
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'}
+2 -2
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>
@@ -515,7 +515,7 @@
</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-sm font-bold text-support hover:text-support px-3 py-0.5"
aria-label={i18n._('menu.donate')} aria-label={i18n._('menu.donate')}
+1 -1
View File
@@ -12,7 +12,7 @@
</script> </script>
<Button <Button
variant="ghost" variant="outline"
size="icon" size="icon"
class={className} class={className}
onclick={() => { onclick={() => {
+9 -10
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>
@@ -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) {
@@ -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>
@@ -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."
@@ -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,18 +64,18 @@
}); });
</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>
@@ -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 (map_ && this._marker) {
if (this._dragging) { if (this._dragging) {
this._marker.remove(); this._hoveredPoint.set(null);
} else { } else {
this._marker.setLngLat(point.coordinates); this._hoveredPoint.set(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();
}
} }
} }
@@ -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}
@@ -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>
@@ -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') {
@@ -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>
@@ -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: {
+43 -40
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>
@@ -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');
@@ -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;
@@ -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);
} }
}) })
); );
@@ -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],
@@ -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));
}
}); });
} }
} }
@@ -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)!
.trkpt.getCoordinates();
const end = statistics
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)! .getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
.trkpt.getCoordinates() .trkpt.getCoordinates();
) data.features.push({
.addTo(map_); 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 { } else {
this.start.remove(); map_.addSource('start-end-markers', {
this.end.remove(); 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 {
if (map_.getLayer('start-end-markers')) {
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);
} }
} }
@@ -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,14 +41,9 @@
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 (
tileUrls[0].includes('.json') ||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
) {
return 'vector'; 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'] = {};
@@ -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);
@@ -167,11 +167,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 +213,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
} }
@@ -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
} }
@@ -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>`
`); );
}
}); });
} }
} }
@@ -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);
@@ -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;
}
}
+8 -8
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);
} }
} }
+85 -153
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);
try {
if (!map.getSource(source)) {
map.addSource(source, terrainSources[source]);
}
if (map.getPitch() > 0) {
map.setTerrain({
source: source,
exaggeration: 1,
});
} else { } else {
map.setTerrain(null); this._onLoadCallbacks.push(callback);
} }
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
return;
} }
callOnLoad() {
if (this._map && this._map.getLayer(ANCHOR_LAYER_KEY.overlays)) {
this._onLoadCallbacks.forEach((callback) => callback(this._map!));
this._onLoadCallbacks = [];
this._map.off('style.load', this.callOnLoadBinded);
} }
} }
} }
export const map = new MapboxGLMap(); export const map = new MapLibreGLMap();
@@ -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
);
}); });
}); });
@@ -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}`
); );
@@ -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 &&
+234
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,
};
}
}
@@ -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,
}); });
@@ -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) {
@@ -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;
@@ -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}`
File diff suppressed because it is too large Load Diff
@@ -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: 'motorcycle' },
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),
}) })
); );
@@ -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) {
@@ -36,7 +36,7 @@
onMount(() => { onMount(() => {
if ($map) { if ($map) {
splitControls = new SplitControls($map); splitControls = new SplitControls($map, map.layerEventManager!);
} }
}); });
@@ -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);
}
};
// 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="white" /> <circle cx="20" cy="20" r="20" fill="white" />
<g transform="translate(8 8)"> <g transform="translate(8 8)">
${Scissors.replace('stroke="currentColor"', 'stroke="black"')} ${Scissors.replace('stroke="currentColor"', 'stroke="black"')}
</g> </g>
</svg> </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),
@@ -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',
}) })
-13
View File
@@ -1,13 +0,0 @@
<script>
import { HeartHandshake } from '@lucide/svelte';
</script>
## <HeartHandshake size="18" class="inline-block align-baseline" /> Help keep the website free (and ad-free)
Each time you add or move GPS points, our servers calculate the best route on the road network.
We also use APIs from <a href="https://maptiler.com" target="_blank">MapTiler</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
Unfortunately, this is expensive.
If you enjoy using this tool and find it valuable, please consider making a small donation to help keep the website free and ad-free.
Thank you very much for your support! ❤️
-5
View File
@@ -1,5 +0,0 @@
Mapbox is the company that provides some of the beautiful maps on this website.
They also develop the <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">map engine</a> which powers **gpx.studio**.
We are incredibly fortunate and grateful to be part of their <a href="https://mapbox.com/community" target="_blank">Community</a> program, which supports nonprofits, educational institutions, and positive impact organizations.
This partnership allows **gpx.studio** to benefit from Mapbox tools at discounted prices, greatly contributing to the financial viability of the project and enabling us to offer the best possible user experience.
@@ -1,12 +0,0 @@
<script>
import { Languages } from '@lucide/svelte';
</script>
## <Languages size="18" class="inline-block align-baseline" /> Translation
The website is translated by volunteers using a collaborative translation platform.
You can contribute by adding or improving translations on our <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin project</a>.
If you would like to start translating into a new language, please <a href="#contact">get in touch</a>.
Any help is greatly appreciated!
-13
View File
@@ -1,13 +0,0 @@
<script>
import { HeartHandshake } from '@lucide/svelte';
</script>
## <HeartHandshake size="18" class="inline-block align-baseline" /> Ajuda a mantenir aquesta pàgina web gratuïta (i sense anuncis)
Cada cop que afegeixes o mous un punt GPS, els nostres servidors calculen la millor ruta possible.
We also use APIs from <a href="https://maptiler.com" target="_blank">MapTiler</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
Desafortunadament, això és car.
Si gaudeixes aquesta eina i la trobes valuosa, si us plau, considera fer una petita donació per ajudar a mantenir la pàgina web gratuïta i sense anuncis.
Moltíssimes gràcies pel teu suport! ❤️
-5
View File
@@ -1,5 +0,0 @@
Mapbox és l'empresa que ofereix alguns dels mapes d'aquest lloc web.
Ells també desenvolupen el <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">motor de mapes</a> el qual recolza **gpx.studio**.
Som increïblement afortunats i estem agraïts de formar part del seu programa <a href="https://mapbox.com/community" target="_blank">comunitari</a>, que dona suport a organitzacions sense ànim de lucre, institucions educatives i organitzacions d'impacte positiu.
Aquesta associació permet a **gpx.studio** beneficiar-se de les eines de Mapbox a preus amb descompte, contribuint en gran mesura a la viabilitat financera del projecte i permetent oferir la millor experiència d'usuari possible.
@@ -1,12 +0,0 @@
<script>
import { Languages } from '@lucide/svelte';
</script>
## <Languages size="18" class="inline-block align-baseline" /> Traducció
Aquesta pàgina web ha estat traduïda per voluntaris utilitzant una plataforma de traducció col·laborativa.
Tu també pots contribuir-hi afegint o millorant les traduccions al nostre <a href="https://crowdin.com/project/gpxstudio" target="_blank">projecte de Crowdin</a>.
Si vols començar a traduir ara mateix a una nova llengua, si us plau <a href="#contact">posa't en contacte amb nosaltres</a>.
Qualsevol ajuda és molt apreciada!
-13
View File
@@ -1,13 +0,0 @@
<script>
import { HeartHandshake } from '@lucide/svelte';
</script>
## <HeartHandshake size="18" class="inline-block align-baseline" /> Pomozte udržet web zdarma (a bez reklam)
Vždy, když přidáte nebo přesunete GPS body, naše servery vypočítají nejlepší cestu po silniční síti.
We also use APIs from <a href="https://maptiler.com" target="_blank">MapTiler</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
Bohužel, to vše je nákladné.
Pokud rádi používáte tento nástroj a zdá se vám hodnotný, zvažte prosím malý příspěvek k udržení webu zdarma a bez reklam.
Děkujeme za vaši podporu! ❤️
-5
View File
@@ -1,5 +0,0 @@
Mapbox je společnost poskytující některé z krásných map na tomto webu.
Vyvíjí také <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">mapový engine</a> na jehož základě provozujeme **gpx.studio**.
Jsme velmi rádi a vděční za to, že můžeme být součástí jejich <a href="https://mapbox.com/community" target="_blank">komunitního</a> programu, který podporuje neziskové a vzdělávací organizace a organizace s pozitivním dopadem.
Toto partnerství umožňuje **gpx.studio** využívat nástroje Mapboxu se slevou a přináší projektu finanční udržitelnost. Díky tomu vám můžeme nabídnout tu nejlepší uživatelskou přívětivost.
@@ -1,12 +0,0 @@
<script>
import { Languages } from '@lucide/svelte';
</script>
## <Languages size="18" class="inline-block align-baseline" /> Překlad
Tento web je překládán dobrovolníky prostřednictvím kolaborativní překladatelské platformy.
Ke zlepšení překladů můžete přispět na našem <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin projektu</a>.
Pokud byste chtěli zahájit překlad do nového jazyka, <a href="#contact">ozvěte se nám</a>, prosím.
Jakákoliv pomoc je velmi ceněna!
-13
View File
@@ -1,13 +0,0 @@
<script>
import { HeartHandshake } from '@lucide/svelte';
</script>
## <HeartHandshake size="18" class="inline-block align-baseline" /> Help keep the website free (and ad-free)
Each time you add or move GPS points, our servers calculate the best route on the road network.
We also use APIs from <a href="https://maptiler.com" target="_blank">MapTiler</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
Unfortunately, this is expensive.
If you enjoy using this tool and find it valuable, please consider making a small donation to help keep the website free and ad-free.
Mange tak for din støtte! ❤️
-5
View File
@@ -1,5 +0,0 @@
Mapbox is the company that provides some of the beautiful maps on this website.
They also develop the <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">map engine</a> which powers **gpx.studio**.
We are incredibly fortunate and grateful to be part of their <a href="https://mapbox.com/community" target="_blank">Community</a> program, which supports nonprofits, educational institutions, and positive impact organizations.
Dette partnerskab tillader **gpx. tudio-** at drage fordel af Mapbox værktøjer til nedsatte priser i høj grad bidrage til projektets finansielle levedygtighed og sætte os i stand til at tilbyde den bedst mulige brugeroplevelse.
@@ -1,12 +0,0 @@
<script>
import { Languages } from '@lucide/svelte';
</script>
## <Languages size="18" class="inline-block align-baseline" /> Translation
Hjemmesiden er oversat af frivillige ved hjælp af en kollaborativ oversættelsesplatform.
You can contribute by adding or improving translations on our <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin project</a>.
If you would like to start translating into a new language, please <a href="#contact">get in touch</a>.
Enhver hjælp er værdsat!
-13
View File
@@ -1,13 +0,0 @@
<script>
import { HeartHandshake } from '@lucide/svelte';
</script>
## <HeartHandshake size="18" class="inline-block align-baseline" /> Helfen Sie, die Website kostenlos (und werbefrei) zu erhalten
Jedes Mal, wenn Sie GPS-Punkte hinzufügen oder verschieben, berechnen unsere Server die beste Route im Straßennetz.
We also use APIs from <a href="https://maptiler.com" target="_blank">MapTiler</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
Leider ist dies mit hohen Kosten verbunden.
Wenn Sie dieses Tool gerne verwenden und es wertvoll finden, erwägen Sie bitte eine kleine Spende, um die Website kostenlos und werbefrei zu halten.
Herzlichen Dank für Ihre Unterstützung! ❤️
-5
View File
@@ -1,5 +0,0 @@
Mapbox stellt einige der auf dieser Website verwendeten Karten bereit.
Sie entwickeln auch die <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">Karten-Engine</a>, die **gpx.studio** unterstützt.
Wir sind froh und dankbar, Teil ihres <a href="https://mapbox.com/community" target="_blank">Community</a> Programms zu sein, das gemeinnützige Organisationen, Bildungseinrichtungen und Organisationen unterstützt.
Diese Partnerschaft ermöglicht es **gpx.studio**, von den Mapbox-Tools zu ermäßigten Preisen zu profitieren, was erheblich zur finanziellen Tragfähigkeit des Projekts beiträgt und es uns ermöglicht, die bestmögliche Benutzererfahrung zu bieten.
@@ -1,12 +0,0 @@
<script>
import { Languages } from '@lucide/svelte';
</script>
## <Languages size="18" class="inline-block align-baseline" /> Übersetzung
Die Webseite wird von Freiwilligen mit einer gemeinsamen Übersetzungsplattform übersetzt.
Sie können dazu beitragen, indem Sie Übersetzungen in unserem <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin Projekt</a> hinzufügen oder verbessern.
Wenn Sie mit der Übersetzung in einer neuen Sprache beginnen möchten, kontaktieren Sie uns bitte <a href="#contact">Kontaktaufnahme</a>.
Jede Hilfe ist sehr willkommen!
-13
View File
@@ -1,13 +0,0 @@
<script>
import { HeartHandshake } from '@lucide/svelte';
</script>
## <HeartHandshake size="18" class="inline-block align-baseline" /> Help keep the website free (and ad-free)
Each time you add or move GPS points, our servers calculate the best route on the road network.
We also use APIs from <a href="https://maptiler.com" target="_blank">MapTiler</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
Unfortunately, this is expensive.
If you enjoy using this tool and find it valuable, please consider making a small donation to help keep the website free and ad-free.
Thank you very much for your support! ❤️
-5
View File
@@ -1,5 +0,0 @@
Mapbox is the company that provides some of the beautiful maps on this website.
They also develop the <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">map engine</a> which powers **gpx.studio**.
We are incredibly fortunate and grateful to be part of their <a href="https://mapbox.com/community" target="_blank">Community</a> program, which supports nonprofits, educational institutions, and positive impact organizations.
This partnership allows **gpx.studio** to benefit from Mapbox tools at discounted prices, greatly contributing to the financial viability of the project and enabling us to offer the best possible user experience.
@@ -1,12 +0,0 @@
<script>
import { Languages } from '@lucide/svelte';
</script>
## <Languages size="18" class="inline-block align-baseline" />Μετάφραση
Αυτός ο ιστότοπος μεταφράζεται από εθελοντές μέσω μια πλατφόρμας συνεργατικής μετάφρασης.
Μπορείτε να συνεισφέρετε προσθέτοντας ή βελτιώνοντας μεταφράσεις στο <a href="https://crowdin.com/project/gpxstudio" target="_blank"> Crowdin έργο</a>.
Αν θέλετε να ξεκινήσετε μετάφραση μιας νέας γλώσσας, παρακαλώ <a href="#contact">επικοινωνήστε μαζί μας<a href="#contact">.
Οποιαδήποτε βοήθεια εκτιμάται ιδιαίτερα!
+1 -1
View File
@@ -29,7 +29,7 @@ Change the language used in the interface.
<DocsNote> <DocsNote>
Μπορείτε να συνεισφέρετε προσθέτοντας ή βελτιώνοντας μεταφράσεις στο <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin έργο</a>. Μπορείτε να συνεισφέρετε προσθέτοντας ή βελτιώνοντας μεταφράσεις στο <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin έργο</a>.
Αν θέλετε να ξεκινήσετε μετάφραση μιας νέας γλώσσας, παρακαλώ <a href="#contact">επικοινωνήστε μαζί μας<a href="#contact">. Αν θέλετε να ξεκινήσετε μετάφραση μιας νέας γλώσσας, παρακαλώ <a href="#contact">επικοινωνήστε μαζί μας</a>.
Οποιαδήποτε βοήθεια εκτιμάται ιδιαίτερα! Οποιαδήποτε βοήθεια εκτιμάται ιδιαίτερα!
</DocsNote> </DocsNote>
+3 -4
View File
@@ -12,6 +12,7 @@ title: Files and statistics
let gpxStatistics = writable(exampleGPXFile.getStatistics()); let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined); let slicedGPXStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']); let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined); let elevationFill = writable(undefined);
</script> </script>
@@ -84,20 +85,18 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<ElevationProfile <ElevationProfile
{gpxStatistics} {gpxStatistics}
{slicedGPXStatistics} {slicedGPXStatistics}
{hoveredPoint}
{additionalDatasets} {additionalDatasets}
{elevationFill} {elevationFill}
/> />
</div> </div>
<div class="flex flex-col items-center -mt-6"> <div class="flex flex-col items-center w-full">
<div class="h-10 w-fit">
<GPXStatistics <GPXStatistics
{gpxStatistics} {gpxStatistics}
{slicedGPXStatistics} {slicedGPXStatistics}
panelSize={120}
orientation={'horizontal'} orientation={'horizontal'}
/> />
</div> </div>
</div>
### Additional data ### Additional data
-13
View File
@@ -1,13 +0,0 @@
<script>
import { HeartHandshake } from '@lucide/svelte';
</script>
## <HeartHandshake size="18" class="inline-block align-baseline" /> Help keep the website free (and ad-free)
Each time you add or move GPS points, our servers calculate the best route on the road network.
We also use APIs from <a href="https://mapbox.com" target="_blank">Mapbox</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
Unfortunately, this is expensive.
If you enjoy using this tool and find it valuable, please consider making a small donation to help keep the website free and ad-free.
Thank you very much for your support! ❤️
-5
View File
@@ -1,5 +0,0 @@
Mapbox is the company that provides some of the beautiful maps on this website.
They also develop the <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">map engine</a> which powers **gpx.studio**.
We are incredibly fortunate and grateful to be part of their <a href="https://mapbox.com/community" target="_blank">Community</a> program, which supports nonprofits, educational institutions, and positive impact organizations.
This partnership allows **gpx.studio** to benefit from Mapbox tools at discounted prices, greatly contributing to the financial viability of the project and enabling us to offer the best possible user experience.
@@ -1,12 +0,0 @@
<script>
import { Languages } from '@lucide/svelte';
</script>
## <Languages size="18" class="inline-block align-baseline" /> Translation
The website is translated by volunteers using a collaborative translation platform.
You can contribute by adding or improving translations on our <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin project</a>.
If you would like to start translating into a new language, please <a href="#contact">get in touch</a>.
Any help is greatly appreciated!
+2 -2
View File
@@ -12,8 +12,8 @@ title: Integration
You can use **gpx.studio** to create maps showing your GPX files and embed them in your website. You can use **gpx.studio** to create maps showing your GPX files and embed them in your website.
All you need is: All you need is:
1. A <a href="https://account.mapbox.com/auth/signup" target="_blank">Mapbox access token</a> to load the map, and 1. GPX files hosted on your server or on Google Drive, or accessible via a public URL;
1. GPX files hosted on your server or on Google Drive, or accessible via a public URL. 1. *Optional:* a <a href="https://cloud.maptiler.com/auth/widget?next=https://cloud.maptiler.com/maps/" target="_blank">MapTiler key</a> to load MapTiler maps.
You can then play with the configurator below to customize your map and generate the corresponding HTML code. You can then play with the configurator below to customize your map and generate the corresponding HTML code.
+4 -2
View File
@@ -58,7 +58,9 @@ Only one basemap can be displayed at a time.
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<DocsLayers /> <DocsLayers />
<span class="text-sm text-center mt-2"> <span class="text-sm text-center mt-2">
Hover over the map to show the <a href="https://hiking.waymarkedtrails.org" target="_blank">Waymarked Trails hiking</a> overlay on top of the <a href="https://www.mapbox.com/maps/outdoors" target="_blank">Mapbox Outdoors</a> basemap.
Hover over the map to show the <a href="https://hiking.waymarkedtrails.org" target="_blank">Waymarked Trails hiking</a> overlay on top of the <a href="https://www.maptiler.com/maps/outdoor-topo/" target="_blank">MapTiler Topo</a> basemap.
</span> </span>
</div> </div>
@@ -67,4 +69,4 @@ They can be enabled in the [map layer settings dialog](./menu/settings).
In these settings, you can also manage the opacity of the overlays. In these settings, you can also manage the opacity of the overlays.
For advanced users, it is possible to add custom basemaps and overlays by providing <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a>, or <a href="https://docs.mapbox.com/help/glossary/style/" target="_blank">Mapbox style JSON</a> URLs. For advanced users, it is possible to add custom basemaps and overlays by providing <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a>, or <a href="https://maplibre.org/maplibre-style-spec/" target="_blank">MapLibre style JSON</a> URLs.
@@ -18,7 +18,7 @@ This tool allows you to add elevation data to traces and [points of interest](..
<DocsNote> <DocsNote>
Elevation data is provided by <a href="https://mapbox.com" target="_blank">Mapbox</a>. Elevation data is provided by <a href="https://maptiler.com" target="_blank">MapTiler</a>.
You can learn more about its origin and accuracy in the <a href="https://docs.mapbox.com/data/tilesets/reference/mapbox-terrain-dem-v1/#elevation-data" target="_blank">documentation</a>. You can learn more about its origin and accuracy in the <a href="https://docs.maptiler.com/guides/map-tiling-hosting/data-hosting/rgb-terrain-by-maptiler/" target="_blank">documentation</a>.
</DocsNote> </DocsNote>
@@ -5,6 +5,7 @@ title: Merge
<script> <script>
import { Group } from '@lucide/svelte'; import { Group } from '@lucide/svelte';
import Merge from '$lib/components/toolbar/tools/Merge.svelte'; import Merge from '$lib/components/toolbar/tools/Merge.svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script> </script>
# <Group size="24" class="inline-block" style="margin-bottom: 5px" /> { title } # <Group size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
@@ -15,6 +16,13 @@ To use this tool, you need to [select](../files-and-stats) multiple files, [trac
- The second option can be used to create or manage files with multiple [tracks or segments](../gpx). - The second option can be used to create or manage files with multiple [tracks or segments](../gpx).
Merging files (or tracks) will result in a single file (or track) containing all tracks (or segments) from the selection. Merging files (or tracks) will result in a single file (or track) containing all tracks (or segments) from the selection.
<DocsNote>
Selected items are merged in the order they appear in the files list.
Reorder items by drag-and-drop if needed.
</DocsNote>
<div class="flex flex-row justify-center"> <div class="flex flex-row justify-center">
<Merge class="text-foreground p-3 border rounded-md shadow-lg" /> <Merge class="text-foreground p-3 border rounded-md shadow-lg" />
</div> </div>
-13
View File
@@ -1,13 +0,0 @@
<script>
import { HeartHandshake } from '@lucide/svelte';
</script>
## <HeartHandshake size="18" class="inline-block align-baseline" /> Ayude a mantener este sitio gratis (y libre de anuncios)
Cada vez que añade o mueve puntos GPS, nuestros servidores calculan la mejor ruta en la red de carreteras.
We also use APIs from <a href="https://maptiler.com" target="_blank">MapTiler</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
Por desgracia, esto tiene un coste económico.
Si disfruta usando esta herramienta y la encuentra valiosa, por favor considere hacer una pequeña donación para ayudar a mantener este sitio gratis y libre de anuncios.
¡Muchas gracias por su apoyo! ❤️
-5
View File
@@ -1,5 +0,0 @@
Mapbox es la empresa que proporciona algunos de los hermosos mapas de este sitio.
Ellos también desarrollan el <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">motor de mapas</a> que impulsa **gpx.studio**.
Somos increíblemente afortunados y agradecemos se parte de su programa <a href="https://mapbox.com/community" target="_blank">Comunidad</a>, que apoya a organizaciones sin ánimo de lucro, instituciones educativas y de impacto positivo.
Esta asociación permite a **gpx.studio** beneficiarse de las herramientas de Mapbox a precios con descuento, contribuyendo en gran medida a la viabilidad financiera del proyecto y permitiéndonos ofrecer la mejor experiencia de usuario posible.
@@ -1,12 +0,0 @@
<script>
import { Languages } from '@lucide/svelte';
</script>
## <Languages size="18" class="inline-block align-baseline" /> Traducción
Este sitio está traducido por voluntarios usando una plataforma de traducción colaborativa.
Puede contribuir añadiendo o mejorando las traducciones en nuestro <a href="https://crowdin.com/project/gpxstudio" target="_blank">proyecto Crowdin</a>.
Si quiere comenzar la traducción en un nuevo idioma, por favor <a href="#contact">contáctenos</a>.
¡Tu ayuda es bienvenida!

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