613 Commits

Author SHA1 Message Date
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 375204c379 New Crowdin updates (#304)
* New translations en.json (Dutch)

* New translations en.json (Czech)

* New translations en.json (Spanish)

* New translations file.mdx (Vietnamese)

* New translations en.json (Polish)
2026-01-28 17:54:01 +01:00
vcoppe d76c03af4f add try catch to setTerrain 2026-01-28 17:53:26 +01:00
vcoppe 200a6586ba remove unused glyphs url with empty key causing problems 2026-01-28 17:53:12 +01:00
vcoppe f0f1ecb2df New Crowdin updates (#303)
* New translations en.json (Spanish)

* New translations en.json (German)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Belarusian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Chinese Simplified)

* New translations en.json (Polish)

* New translations en.json (Italian)

* New translations en.json (Hungarian)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* 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 (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 mapbox.mdx (German)

* New translations en.json (Chinese Simplified)

* New translations en.json (Polish)

* New translations en.json (Spanish)

* New translations en.json (German)

* New translations en.json (Italian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Belarusian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* 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 (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 (French)
2026-01-16 20:06:44 +01:00
vcoppe 2eb6ef6f03 new setting for selecting terrain source 2026-01-16 19:16:28 +01:00
vcoppe f7c0805161 add mapterhorn hillshade overlay, closes #292 2026-01-16 18:32:32 +01:00
vcoppe 4e18e3c8a0 update year 2026-01-16 18:25:27 +01:00
vcoppe 59f31caf26 add openrailwaymap overlay, closes #298 2026-01-11 20:18:00 +01:00
vcoppe f24956c58d improve grouping statistics performance 2026-01-11 19:48:48 +01:00
vcoppe 9019317e5c fix prettier paths, continued 2026-01-11 19:06:54 +01:00
vcoppe 2a0227c1de New Crowdin updates (#300)
* New translations en.json (Spanish)

* New translations en.json (German)

* New translations en.json (Italian)

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

* New translations gpx.mdx (Italian)

* New translations funding.mdx (Italian)

* New translations en.json (French)
2026-01-11 11:09:41 +01:00
vcoppe f70db42b91 New Crowdin updates (#294)
* New translations en.json (Vietnamese)

* New translations en.json (Vietnamese)

* New translations funding.mdx (Vietnamese)

* New translations mapbox.mdx (Vietnamese)

* New translations edit.mdx (Vietnamese)

* New translations file.mdx (Chinese Simplified)

* New translations en.json (Chinese Simplified)

* New translations en.json (Polish)
2026-01-04 21:39:32 +01:00
vcoppe 9cd87742f0 avoid performing a get on unit stores for each point 2026-01-04 19:52:25 +01:00
vcoppe 5dcb93ca5d fix loading style font 2026-01-04 19:29:46 +01:00
vcoppe 256d62b29b only perform layer selection checks when settings are open 2026-01-04 19:00:13 +01:00
vcoppe 595ea8e2d3 revert clone function switch 2025-12-26 14:17:23 +01:00
vcoppe d3e733aa3e fix wpt colors 2025-12-24 16:34:40 +01:00
vcoppe a011768d2d computeStatistics compatible with read-only segment 2025-12-24 16:12:44 +01:00
vcoppe 4b45b5d716 update bits-ui 2025-12-24 15:27:44 +01:00
vcoppe ebe9681c12 avoid creating useless data 2025-12-24 13:31:04 +01:00
vcoppe 51c85e4cd5 fix wpt to segment matching 2025-12-24 13:07:22 +01:00
vcoppe 2e171dfbee speed up wpt to segment matching 2025-12-24 12:43:24 +01:00
vcoppe a6a3917986 improve statistics tree performance 2025-12-24 12:21:27 +01:00
vcoppe 21f2448213 improve cloning performance 2025-12-24 12:03:36 +01:00
vcoppe e7a1d0488b fix ts error 2025-12-24 10:23:15 +01:00
vcoppe 22b8e0edb4 update chartjs 2025-12-24 10:11:43 +01:00
vcoppe d062a38e8f new mapbox version 2025-12-24 08:48:50 +01:00
vcoppe affa59130f simplify filter for hiding layers 2025-12-24 08:48:21 +01:00
vcoppe 3c816567bc use explicit path for prettierrc file, closes #289 2025-12-23 17:34:34 +01:00
vcoppe e92e48ffde New Crowdin updates (#290)
* New translations en.json (Spanish)

* New translations getting-started.mdx (Dutch)

* New translations edit.mdx (Dutch)

* New translations en.json (Basque)

* New translations file.mdx (Basque)

* New translations en.json (Chinese Simplified)

* New translations file.mdx (Chinese Simplified)
2025-12-18 17:41:09 +01:00
vcoppe 4ce7777b86 New Crowdin updates (#286)
* New translations en.json (Italian)

* New translations file.mdx (Italian)
2025-12-08 20:18:25 +01:00
vcoppe bc130ad867 use current zoom for osm edit link 2025-12-08 20:17:57 +01:00
vcoppe 867b6a6ac7 New Crowdin updates (#285)
* New translations en.json (German)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations view.mdx (Spanish)

* New translations en.json (Belarusian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (Greek)

* New translations en.json (Basque)

* 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 (Dutch)

* 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 (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 (Chinese Simplified)

* New translations en.json (German)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Belarusian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (Greek)

* New translations en.json (Basque)

* 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 (Dutch)

* 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 (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 (French)

* New translations en.json (Czech)

* New translations en.json (Dutch)
2025-12-07 18:56:45 +01:00
vcoppe e585fd084c edit in osm button in context menu, closes #282 2025-12-07 14:27:07 +01:00
vcoppe b47bb4a771 fix overpass layers, and add cemeteries, closes #235 2025-12-07 14:11:31 +01:00
vcoppe 9cff71fed3 fix select components height 2025-12-07 12:15:40 +01:00
vcoppe e76040e416 add X icon for crossing, ref #261 2025-12-07 12:13:03 +01:00
vcoppe 1facf50621 show selected icon in waypoint tool 2025-12-07 12:10:56 +01:00
vcoppe 57f3cc8bc0 fix alignment 2025-12-07 11:48:42 +01:00
vcoppe 1ab3fe1c4a use latest linz topo style 2025-12-03 21:51:24 +01:00
vcoppe 10cff632fd add structured data 2025-12-03 13:53:35 +01:00
vcoppe 4105687c0a fix icon size 2025-12-02 21:49:15 +01:00
vcoppe 8fe6565527 use browser navigation for /app 2025-11-28 17:48:21 +01:00
vcoppe 69b018022d fix waypoint default sym value 2025-11-27 08:01:05 +01:00
vcoppe 467cb2e589 update extension api 2025-11-25 19:18:54 +01:00
vcoppe f13d8c1e22 New translations en.json (Chinese Simplified) (#280) 2025-11-25 18:23:13 +01:00
vcoppe e230d55b82 fix cloning 2025-11-25 18:22:51 +01:00
vcoppe 46fcdb4bb2 elevation gain computation hybrid between ramer-douglas-peucker and smoothing 2025-11-21 20:20:42 +01:00
vcoppe 429212ef23 use standard sprite path 2025-11-20 08:53:24 +01:00
vcoppe 4ea0ea6a7a update mapbox 2025-11-20 00:27:53 +01:00
vcoppe 2e3ce83605 fix null check 2025-11-20 00:25:56 +01:00
vcoppe fda908dd0d listen to touchstart event on layer 2025-11-19 23:45:28 +01:00
vcoppe cad77e2b10 try fix dragging on touch devices 2025-11-19 23:36:02 +01:00
vcoppe 3542a7c24d New translations en.json (Polish) (#273) 2025-11-19 23:01:01 +01:00
vcoppe 0d6d161e23 add missing keyboard navigation, closes #277 2025-11-19 23:00:33 +01:00
vcoppe 89a2e0086b preview new POI 2025-11-19 22:43:19 +01:00
vcoppe cd443faf61 cancel drag on click 2025-11-19 22:28:40 +01:00
vcoppe bfc56b02a8 use map layer instead of markers for POIs 2025-11-19 21:59:17 +01:00
vcoppe 25bafc6bf1 improve bounds filtering 2025-11-17 22:53:48 +01:00
vcoppe 6387580626 speed up split controls 2025-11-16 16:46:31 +01:00
vcoppe 09b8aa65fc fix speed computation when no time data 2025-11-16 15:05:59 +01:00
vcoppe 6c15193f32 rename file to avoid clashes 2025-11-16 13:27:15 +01:00
vcoppe 4442e29b66 use better height and width units 2025-11-16 13:22:26 +01:00
vcoppe b6f96d9f4d simplify computations 2025-11-16 13:04:47 +01:00
vcoppe 36b66100f9 fix time input handling 2025-11-16 12:13:55 +01:00
vcoppe 49d8143cc6 fix local elevation gain computation 2025-11-16 11:09:37 +01:00
vcoppe fc279fecaf improve distance computation 2025-11-15 09:05:25 +01:00
vcoppe bd307daa57 improve elevation gain computation 2025-11-15 08:39:42 +01:00
vcoppe 7a72f44722 improve speed computation 2025-11-15 07:46:21 +01:00
vcoppe 8e63fc6946 speed up simplify by using more naive distance 2025-11-15 07:17:11 +01:00
vcoppe 3a65f8dc16 New Crowdin updates (#270)
* New translations en.json (Romanian)

* New translations en.json (Spanish)

* New translations en.json (Belarusian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* 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 (Dutch)

* 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 (French)
2025-11-14 18:56:43 +01:00
vcoppe d624352a0b improve contrast for selection 2025-11-14 18:45:49 +01:00
vcoppe 3fb597a774 fix page title flickering 2025-11-14 18:45:38 +01:00
vcoppe 85e46bc524 add basemap in basemap tree when missing in embedding mode 2025-11-14 18:45:20 +01:00
vcoppe d3790878b3 remove x 2025-11-14 18:44:32 +01:00
vcoppe e648e50f1b remove unused file 2025-11-14 18:44:16 +01:00
vcoppe cb6cede0b6 update license 2025-11-14 18:44:06 +01:00
vcoppe a0157ddc2a New translations file.mdx (Czech) (#268) 2025-11-13 09:06:27 +01:00
vcoppe 8f595fbc7b fix safari incompatibility 2025-11-13 09:06:00 +01:00
vcoppe 2e3b22c5fa update deploy action 2025-11-12 18:26:35 +01:00
vcoppe 42b968372b fix import 2025-11-12 18:14:49 +01:00
vcoppe 415e7b492f New Crowdin updates (#267)
* New translations extract.mdx (Czech)

* New translations extract.mdx (Danish)

* New translations extract.mdx (German)

* New translations extract.mdx (Greek)

* New translations extract.mdx (Basque)

* New translations extract.mdx (Finnish)

* New translations extract.mdx (Hebrew)

* New translations extract.mdx (Hungarian)

* New translations extract.mdx (Italian)

* New translations extract.mdx (Korean)

* New translations extract.mdx (Lithuanian)

* New translations extract.mdx (Dutch)

* New translations extract.mdx (Norwegian)

* New translations extract.mdx (Polish)

* New translations extract.mdx (Portuguese)

* New translations extract.mdx (Russian)

* New translations extract.mdx (Swedish)

* New translations extract.mdx (Turkish)

* New translations extract.mdx (Ukrainian)

* New translations extract.mdx (Chinese Simplified)

* New translations extract.mdx (Vietnamese)

* New translations extract.mdx (Portuguese, Brazilian)

* New translations extract.mdx (Indonesian)

* New translations extract.mdx (Thai)

* New translations extract.mdx (Latvian)

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

* New translations extract.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 minify.mdx (Romanian)

* New translations minify.mdx (French)

* New translations minify.mdx (Spanish)

* New translations minify.mdx (Belarusian)

* New translations minify.mdx (Catalan)

* New translations minify.mdx (Czech)

* New translations minify.mdx (Danish)

* New translations minify.mdx (German)

* New translations minify.mdx (Greek)

* New translations minify.mdx (Basque)

* New translations minify.mdx (Finnish)

* New translations minify.mdx (Hebrew)

* New translations minify.mdx (Hungarian)

* New translations minify.mdx (Italian)

* New translations minify.mdx (Korean)

* New translations minify.mdx (Lithuanian)

* New translations minify.mdx (Dutch)

* New translations minify.mdx (Norwegian)

* New translations minify.mdx (Polish)

* New translations minify.mdx (Portuguese)

* New translations minify.mdx (Russian)

* New translations minify.mdx (Swedish)

* New translations minify.mdx (Turkish)

* New translations minify.mdx (Ukrainian)

* New translations minify.mdx (Chinese Simplified)

* New translations minify.mdx (Vietnamese)

* New translations minify.mdx (Portuguese, Brazilian)

* New translations minify.mdx (Indonesian)

* New translations minify.mdx (Thai)

* New translations minify.mdx (Latvian)

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

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

* New translations poi.mdx (Romanian)

* New translations poi.mdx (French)

* New translations poi.mdx (Spanish)

* New translations poi.mdx (Belarusian)

* New translations poi.mdx (Catalan)

* New translations poi.mdx (Czech)

* New translations poi.mdx (Danish)

* New translations poi.mdx (German)

* New translations poi.mdx (Greek)

* New translations poi.mdx (Basque)

* New translations poi.mdx (Finnish)

* New translations poi.mdx (Hebrew)

* New translations poi.mdx (Hungarian)

* New translations poi.mdx (Italian)

* New translations poi.mdx (Korean)

* New translations poi.mdx (Lithuanian)

* New translations poi.mdx (Dutch)

* New translations poi.mdx (Norwegian)

* New translations poi.mdx (Polish)

* New translations poi.mdx (Portuguese)

* New translations poi.mdx (Russian)

* New translations poi.mdx (Swedish)

* New translations poi.mdx (Turkish)

* New translations poi.mdx (Ukrainian)

* New translations poi.mdx (Chinese Simplified)

* New translations poi.mdx (Vietnamese)

* New translations poi.mdx (Portuguese, Brazilian)

* New translations poi.mdx (Indonesian)

* New translations poi.mdx (Thai)

* New translations poi.mdx (Latvian)

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

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

* New translations routing.mdx (Romanian)

* New translations routing.mdx (French)

* New translations routing.mdx (Spanish)

* New translations routing.mdx (Belarusian)

* New translations routing.mdx (Catalan)

* New translations routing.mdx (Danish)

* New translations routing.mdx (German)

* New translations routing.mdx (Greek)

* New translations routing.mdx (Basque)

* New translations routing.mdx (Finnish)

* New translations routing.mdx (Hebrew)

* New translations routing.mdx (Hungarian)

* New translations routing.mdx (Italian)

* New translations routing.mdx (Korean)

* New translations routing.mdx (Lithuanian)

* New translations routing.mdx (Dutch)

* New translations routing.mdx (Norwegian)

* New translations routing.mdx (Polish)

* New translations routing.mdx (Portuguese)

* New translations routing.mdx (Russian)

* New translations routing.mdx (Swedish)

* New translations routing.mdx (Turkish)

* New translations routing.mdx (Ukrainian)

* New translations routing.mdx (Chinese Simplified)

* New translations routing.mdx (Vietnamese)

* New translations routing.mdx (Portuguese, Brazilian)

* New translations routing.mdx (Indonesian)

* New translations routing.mdx (Thai)

* New translations routing.mdx (Latvian)

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

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

* New translations scissors.mdx (Romanian)

* New translations scissors.mdx (French)

* New translations scissors.mdx (Spanish)

* New translations scissors.mdx (Belarusian)

* New translations scissors.mdx (Catalan)

* New translations scissors.mdx (Czech)

* New translations scissors.mdx (Danish)

* New translations scissors.mdx (German)

* New translations scissors.mdx (Greek)

* New translations scissors.mdx (Basque)

* New translations scissors.mdx (Finnish)

* New translations scissors.mdx (Hebrew)

* New translations scissors.mdx (Hungarian)

* New translations scissors.mdx (Italian)

* New translations scissors.mdx (Korean)

* New translations scissors.mdx (Lithuanian)

* New translations scissors.mdx (Dutch)

* New translations scissors.mdx (Norwegian)

* New translations scissors.mdx (Polish)

* New translations scissors.mdx (Portuguese)

* New translations scissors.mdx (Russian)

* New translations scissors.mdx (Swedish)

* New translations scissors.mdx (Turkish)

* New translations scissors.mdx (Ukrainian)

* New translations scissors.mdx (Chinese Simplified)

* New translations scissors.mdx (Vietnamese)

* New translations scissors.mdx (Portuguese, Brazilian)

* New translations scissors.mdx (Indonesian)

* New translations scissors.mdx (Thai)

* New translations scissors.mdx (Latvian)

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

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

* New translations time.mdx (Romanian)

* New translations time.mdx (French)

* New translations time.mdx (Spanish)

* New translations time.mdx (Belarusian)

* New translations time.mdx (Catalan)

* New translations time.mdx (Czech)

* New translations time.mdx (Danish)

* New translations time.mdx (German)

* New translations time.mdx (Greek)

* New translations time.mdx (Basque)

* New translations time.mdx (Finnish)

* New translations time.mdx (Hebrew)

* New translations time.mdx (Hungarian)

* New translations time.mdx (Italian)

* New translations time.mdx (Korean)

* New translations time.mdx (Lithuanian)

* New translations time.mdx (Dutch)

* New translations time.mdx (Norwegian)

* New translations time.mdx (Polish)

* New translations time.mdx (Portuguese)

* New translations time.mdx (Russian)

* New translations time.mdx (Swedish)

* New translations time.mdx (Turkish)

* New translations time.mdx (Ukrainian)

* New translations time.mdx (Chinese Simplified)

* New translations time.mdx (Vietnamese)

* New translations time.mdx (Portuguese, Brazilian)

* New translations time.mdx (Indonesian)

* New translations time.mdx (Thai)

* New translations time.mdx (Latvian)

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

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

* New translations elevation.mdx (Romanian)

* New translations elevation.mdx (French)

* New translations elevation.mdx (Spanish)

* New translations elevation.mdx (Belarusian)

* New translations elevation.mdx (Catalan)

* New translations elevation.mdx (Czech)

* New translations elevation.mdx (Danish)

* New translations elevation.mdx (German)

* New translations elevation.mdx (Greek)

* New translations elevation.mdx (Basque)

* New translations elevation.mdx (Finnish)

* New translations elevation.mdx (Hebrew)

* New translations elevation.mdx (Hungarian)

* New translations elevation.mdx (Italian)

* New translations elevation.mdx (Korean)

* New translations elevation.mdx (Lithuanian)

* New translations elevation.mdx (Dutch)

* New translations elevation.mdx (Norwegian)

* New translations elevation.mdx (Polish)

* New translations elevation.mdx (Portuguese)

* New translations elevation.mdx (Russian)

* New translations elevation.mdx (Swedish)

* New translations elevation.mdx (Turkish)

* New translations elevation.mdx (Ukrainian)

* New translations elevation.mdx (Chinese Simplified)

* New translations elevation.mdx (Vietnamese)

* New translations elevation.mdx (Portuguese, Brazilian)

* New translations elevation.mdx (Indonesian)

* New translations elevation.mdx (Thai)

* New translations elevation.mdx (Latvian)

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

* New translations elevation.mdx (Serbian (Latin))
2025-11-12 18:03:06 +01:00
vcoppe 2ea8e46723 putting correct import back 2025-11-12 17:45:55 +01:00
vcoppe 977c6c6dde trying to force update the import on crowdin 2025-11-12 17:28:46 +01:00
vcoppe 1e5db9dc6c fix import 2025-11-12 16:17:25 +01:00
vcoppe 252dc10e61 Merge remote-tracking branch 'origin/l10n' into dev 2025-11-12 16:08:18 +01:00
vcoppe f9e2315ba1 only show layer if it has been activated before 2025-11-12 15:50:05 +01:00
vcoppe 0eca588280 update extension api 2025-11-12 14:48:17 +01:00
vcoppe 33523bbfb9 New translations files-and-stats.mdx (Lithuanian) 2025-11-12 12:56:32 +01:00
vcoppe 110f23bdf1 update extension api 2025-11-12 12:47:26 +01:00
vcoppe 50a5cb23f5 remove unused imports 2025-11-12 11:39:51 +01:00
vcoppe 30e72db5ea hide horizontal scroll bar 2025-11-12 09:05:20 +01:00
vcoppe c4c64c8fe8 load files from urls/ids once local ones are loaded 2025-11-12 09:02:09 +01:00
vcoppe df39350d7d New translations file.mdx (Czech) 2025-11-11 18:33:32 +01:00
vcoppe 5daacd3ed4 New translations en.json (Czech) 2025-11-11 18:33:27 +01:00
vcoppe 0f7f64fb2f migrate component 2025-11-11 17:30:06 +01:00
vcoppe b09a1fdcb7 migrate component 2025-11-11 17:23:24 +01:00
vcoppe e5d45dee3a fix hidden computation for new files 2025-11-11 14:03:07 +01:00
vcoppe f3270e19df New translations file.mdx (Dutch) 2025-11-11 13:57:07 +01:00
vcoppe 1b9ad41c87 New translations en.json (Dutch) 2025-11-11 13:57:05 +01:00
vcoppe 8c3365ef24 update nz basemap 2025-11-11 13:00:34 +01:00
vcoppe db5cbffb70 api for adding overlays from extensions 2025-11-11 12:11:38 +01:00
vcoppe c6586f0eed New translations file.mdx (Spanish) 2025-11-11 12:11:02 +01:00
vcoppe f40bdc8ed9 New translations en.json (Spanish) 2025-11-11 12:11:00 +01:00
vcoppe 683ac4e118 clean custom layer logic 2025-11-11 10:37:06 +01:00
vcoppe e5ad8bbb70 New translations file.mdx (French) 2025-11-10 20:34:51 +01:00
vcoppe 7f6acbfdbc Update source file file.mdx 2025-11-10 20:33:50 +01:00
vcoppe 2e070529e0 New translations routing.mdx (Portuguese, Brazilian) 2025-11-10 19:08:15 +01:00
vcoppe f4b31e5f0a New translations routing.mdx (Chinese Simplified) 2025-11-10 19:08:14 +01:00
vcoppe f7f093a464 New translations routing.mdx (Italian) 2025-11-10 19:08:09 +01:00
vcoppe 95cc340de5 New translations routing.mdx (Basque) 2025-11-10 19:08:06 +01:00
vcoppe 51a003c816 New translations routing.mdx (German) 2025-11-10 19:08:04 +01:00
vcoppe 977152139f New translations routing.mdx (Catalan) 2025-11-10 19:08:03 +01:00
vcoppe 78833df95e New translations file.mdx (Serbian (Latin)) 2025-11-10 19:06:14 +01:00
vcoppe 099d941d2e New translations file.mdx (Chinese Traditional, Hong Kong) 2025-11-10 19:06:13 +01:00
vcoppe ea58f378a9 New translations file.mdx (Latvian) 2025-11-10 19:06:11 +01:00
vcoppe 4060884909 New translations file.mdx (Thai) 2025-11-10 19:06:10 +01:00
vcoppe d9277c11d2 New translations file.mdx (Indonesian) 2025-11-10 19:06:09 +01:00
vcoppe bcbc90820a New translations file.mdx (Portuguese, Brazilian) 2025-11-10 19:06:07 +01:00
vcoppe e9caa95673 New translations file.mdx (Vietnamese) 2025-11-10 19:06:06 +01:00
vcoppe 9cd6703b05 New translations file.mdx (Chinese Simplified) 2025-11-10 19:06:05 +01:00
vcoppe 4233bd7771 New translations file.mdx (Ukrainian) 2025-11-10 19:06:03 +01:00
vcoppe 0e2db441f2 New translations file.mdx (Turkish) 2025-11-10 19:06:02 +01:00
vcoppe 571b101ea4 New translations file.mdx (Swedish) 2025-11-10 19:06:01 +01:00
vcoppe 0b9dca61ab New translations file.mdx (Russian) 2025-11-10 19:06:00 +01:00
vcoppe d8fa76d076 New translations file.mdx (Portuguese) 2025-11-10 19:05:58 +01:00
vcoppe 6116eef513 New translations file.mdx (Polish) 2025-11-10 19:05:57 +01:00
vcoppe fabe987f2c New translations file.mdx (Norwegian) 2025-11-10 19:05:56 +01:00
vcoppe af20880f37 New translations file.mdx (Dutch) 2025-11-10 19:05:55 +01:00
vcoppe 44eeab0d4b New translations file.mdx (Lithuanian) 2025-11-10 19:05:53 +01:00
vcoppe b331900158 New translations file.mdx (Korean) 2025-11-10 19:05:52 +01:00
vcoppe d74380404c New translations file.mdx (Italian) 2025-11-10 19:05:51 +01:00
vcoppe 3833a9cd6b New translations file.mdx (Hungarian) 2025-11-10 19:05:50 +01:00
vcoppe 74fdd943c9 New translations file.mdx (Hebrew) 2025-11-10 19:05:49 +01:00
vcoppe ad5b772502 New translations file.mdx (Finnish) 2025-11-10 19:05:47 +01:00
vcoppe 9bc941aa31 New translations file.mdx (Basque) 2025-11-10 19:05:46 +01:00
vcoppe 705df43047 New translations file.mdx (Greek) 2025-11-10 19:05:45 +01:00
vcoppe b31e3bb710 New translations file.mdx (German) 2025-11-10 19:05:44 +01:00
vcoppe 4082d0a368 New translations file.mdx (Danish) 2025-11-10 19:05:42 +01:00
vcoppe ce85286cdf New translations file.mdx (Czech) 2025-11-10 19:05:41 +01:00
vcoppe 415cf1a777 New translations file.mdx (Catalan) 2025-11-10 19:05:40 +01:00
vcoppe f249919ec8 New translations file.mdx (Belarusian) 2025-11-10 19:05:39 +01:00
vcoppe 054c9787d3 New translations file.mdx (Spanish) 2025-11-10 19:05:37 +01:00
vcoppe c1e88e2b5a New translations file.mdx (French) 2025-11-10 19:05:36 +01:00
vcoppe f5efeb16c4 New translations file.mdx (Romanian) 2025-11-10 19:05:35 +01:00
vcoppe 59afae7bca New translations translation.mdx (Portuguese, Brazilian) 2025-11-10 19:04:37 +01:00
vcoppe 92c6339064 New translations translation.mdx (Chinese Simplified) 2025-11-10 19:04:35 +01:00
vcoppe 582ae233f2 New translations translation.mdx (Turkish) 2025-11-10 19:04:34 +01:00
vcoppe 3c3016a211 New translations translation.mdx (Dutch) 2025-11-10 19:04:31 +01:00
vcoppe e24e1d9d3c New translations translation.mdx (Italian) 2025-11-10 19:04:28 +01:00
vcoppe 85fb564be7 New translations translation.mdx (Basque) 2025-11-10 19:04:26 +01:00
vcoppe 18f7db9eee New translations translation.mdx (German) 2025-11-10 19:04:24 +01:00
vcoppe ea0770fd11 New translations translation.mdx (Czech) 2025-11-10 19:04:23 +01:00
vcoppe 9c28fab3f9 New translations translation.mdx (Catalan) 2025-11-10 19:04:22 +01:00
vcoppe bac332a8c4 New translations translation.mdx (Spanish) 2025-11-10 19:04:20 +01:00
vcoppe 86d2542bd9 New translations funding.mdx (Portuguese, Brazilian) 2025-11-10 19:04:02 +01:00
vcoppe 5f63ec884f New translations funding.mdx (Chinese Simplified) 2025-11-10 19:04:00 +01:00
vcoppe 0f87b33354 New translations funding.mdx (Turkish) 2025-11-10 19:03:58 +01:00
vcoppe c7dc99a12f New translations funding.mdx (Dutch) 2025-11-10 19:03:55 +01:00
vcoppe 05c3c3f8f3 New translations funding.mdx (Italian) 2025-11-10 19:03:53 +01:00
vcoppe 2437a43471 New translations funding.mdx (Basque) 2025-11-10 19:03:50 +01:00
vcoppe 16cd812ba0 New translations funding.mdx (German) 2025-11-10 19:03:49 +01:00
vcoppe c036128720 New translations funding.mdx (Czech) 2025-11-10 19:03:47 +01:00
vcoppe 645db15848 New translations funding.mdx (Catalan) 2025-11-10 19:03:45 +01:00
vcoppe 21e142ffc3 New translations funding.mdx (Spanish) 2025-11-10 19:03:44 +01:00
vcoppe 23a6b3db72 New translations funding.mdx (French) 2025-11-10 19:03:43 +01:00
vcoppe b87d109625 New translations routing.mdx (Czech) 2025-11-10 19:03:04 +01:00
vcoppe 651bd295b9 New translations en.json (French) 2025-11-10 19:02:37 +01:00
vcoppe 01607e92b9 Update source file routing.mdx 2025-11-10 19:02:30 +01:00
vcoppe f40d54adbb Update source file poi.mdx 2025-11-10 19:02:29 +01:00
vcoppe 60fb387495 Update source file minify.mdx 2025-11-10 19:02:28 +01:00
vcoppe a7df18723c Update source file toolbar.mdx 2025-11-10 19:02:26 +01:00
vcoppe 4e39cc937a Update source file translation.mdx 2025-11-10 19:02:24 +01:00
vcoppe fe513c17ab Update source file funding.mdx 2025-11-10 19:02:23 +01:00
vcoppe f7fb88ed3d Update source file files-and-stats.mdx 2025-11-10 19:02:22 +01:00
vcoppe b3247c7cbe Update source file en.json 2025-11-10 19:02:21 +01:00
vcoppe 97e79c23f6 New translations routing.mdx (Serbian (Latin)) 2025-11-10 18:51:41 +01:00
vcoppe 7803a29875 New translations routing.mdx (Chinese Traditional, Hong Kong) 2025-11-10 18:51:40 +01:00
vcoppe 08093beed1 New translations routing.mdx (Latvian) 2025-11-10 18:51:38 +01:00
vcoppe ae46ae89bc New translations routing.mdx (Thai) 2025-11-10 18:51:37 +01:00
vcoppe f7977dca39 New translations routing.mdx (Indonesian) 2025-11-10 18:51:36 +01:00
vcoppe 0b344f3e08 New translations routing.mdx (Portuguese, Brazilian) 2025-11-10 18:51:35 +01:00
vcoppe 6058154eca New translations routing.mdx (Vietnamese) 2025-11-10 18:51:34 +01:00
vcoppe 941016f04b New translations routing.mdx (Chinese Simplified) 2025-11-10 18:51:33 +01:00
vcoppe 7a15898087 New translations routing.mdx (Ukrainian) 2025-11-10 18:51:31 +01:00
vcoppe 6b4effa90f New translations routing.mdx (Turkish) 2025-11-10 18:51:30 +01:00
vcoppe 77d56e4a4c New translations routing.mdx (Swedish) 2025-11-10 18:51:29 +01:00
vcoppe c4dd622e7b New translations routing.mdx (Russian) 2025-11-10 18:51:28 +01:00
vcoppe 9f24535142 New translations routing.mdx (Portuguese) 2025-11-10 18:51:26 +01:00
vcoppe a45161fcb8 New translations routing.mdx (Polish) 2025-11-10 18:51:25 +01:00
vcoppe 4379763a73 New translations routing.mdx (Norwegian) 2025-11-10 18:51:23 +01:00
vcoppe a25b376dfd New translations routing.mdx (Dutch) 2025-11-10 18:51:21 +01:00
vcoppe fb429f6777 New translations routing.mdx (Lithuanian) 2025-11-10 18:51:20 +01:00
vcoppe ae33227c3a New translations routing.mdx (Korean) 2025-11-10 18:51:19 +01:00
vcoppe 2fedc61d1e New translations routing.mdx (Italian) 2025-11-10 18:51:18 +01:00
vcoppe 6988a36dd1 New translations routing.mdx (Hungarian) 2025-11-10 18:51:17 +01:00
vcoppe 60e1124970 New translations routing.mdx (Hebrew) 2025-11-10 18:51:15 +01:00
vcoppe a007684006 New translations routing.mdx (Finnish) 2025-11-10 18:51:13 +01:00
vcoppe 090a709522 New translations routing.mdx (Basque) 2025-11-10 18:51:12 +01:00
vcoppe 9e06655214 New translations routing.mdx (Greek) 2025-11-10 18:51:11 +01:00
vcoppe 3651825e79 New translations routing.mdx (German) 2025-11-10 18:51:09 +01:00
vcoppe dcf3160b58 New translations routing.mdx (Danish) 2025-11-10 18:51:08 +01:00
vcoppe fd014f42cd New translations routing.mdx (Catalan) 2025-11-10 18:51:07 +01:00
vcoppe a760f2f7fc New translations routing.mdx (Belarusian) 2025-11-10 18:51:06 +01:00
vcoppe 262c114b7d New translations routing.mdx (Spanish) 2025-11-10 18:51:05 +01:00
vcoppe c90bdd83bb New translations routing.mdx (French) 2025-11-10 18:51:03 +01:00
vcoppe fdb2d37b12 New translations routing.mdx (Romanian) 2025-11-10 18:51:02 +01:00
vcoppe 7951837ecf New translations poi.mdx (Serbian (Latin)) 2025-11-10 18:51:00 +01:00
vcoppe d2e112a672 New translations poi.mdx (Chinese Traditional, Hong Kong) 2025-11-10 18:50:59 +01:00
vcoppe 90e86077ba New translations poi.mdx (Latvian) 2025-11-10 18:50:58 +01:00
vcoppe f69dfcdefe New translations poi.mdx (Thai) 2025-11-10 18:50:57 +01:00
vcoppe 5cff7c4e72 New translations poi.mdx (Indonesian) 2025-11-10 18:50:56 +01:00
vcoppe 0eaa833cdf New translations poi.mdx (Portuguese, Brazilian) 2025-11-10 18:50:55 +01:00
vcoppe db015d925e New translations poi.mdx (Vietnamese) 2025-11-10 18:50:54 +01:00
vcoppe 69fd47601e New translations poi.mdx (Chinese Simplified) 2025-11-10 18:50:52 +01:00
vcoppe 17521574f0 New translations poi.mdx (Ukrainian) 2025-11-10 18:50:51 +01:00
vcoppe 56650a76ae New translations poi.mdx (Turkish) 2025-11-10 18:50:49 +01:00
vcoppe 984a98e792 New translations poi.mdx (Swedish) 2025-11-10 18:50:48 +01:00
vcoppe 49eb0cf202 New translations poi.mdx (Russian) 2025-11-10 18:50:47 +01:00
vcoppe 8e24a6b036 New translations poi.mdx (Portuguese) 2025-11-10 18:50:46 +01:00
vcoppe d07bf6d699 New translations poi.mdx (Polish) 2025-11-10 18:50:45 +01:00
vcoppe 877d12c676 New translations poi.mdx (Norwegian) 2025-11-10 18:50:44 +01:00
vcoppe ca629f625a New translations poi.mdx (Dutch) 2025-11-10 18:50:43 +01:00
vcoppe 087dd9a4b6 New translations poi.mdx (Lithuanian) 2025-11-10 18:50:41 +01:00
vcoppe d9a967c072 New translations poi.mdx (Korean) 2025-11-10 18:50:40 +01:00
vcoppe 6d522c82c3 New translations poi.mdx (Italian) 2025-11-10 18:50:39 +01:00
vcoppe 867af98083 New translations poi.mdx (Hungarian) 2025-11-10 18:50:38 +01:00
vcoppe 1be058d831 New translations poi.mdx (Hebrew) 2025-11-10 18:50:37 +01:00
vcoppe 71bc044ae5 New translations poi.mdx (Finnish) 2025-11-10 18:50:36 +01:00
vcoppe d660c50ade New translations poi.mdx (Basque) 2025-11-10 18:50:35 +01:00
vcoppe 3fd733d903 New translations poi.mdx (Greek) 2025-11-10 18:50:33 +01:00
vcoppe 7703b2361d New translations poi.mdx (German) 2025-11-10 18:50:32 +01:00
vcoppe 68fdb9ebc6 New translations poi.mdx (Danish) 2025-11-10 18:50:31 +01:00
vcoppe b6513343be New translations poi.mdx (Czech) 2025-11-10 18:50:30 +01:00
vcoppe cc95ff1c55 New translations poi.mdx (Catalan) 2025-11-10 18:50:29 +01:00
vcoppe c954ee0fde New translations poi.mdx (Belarusian) 2025-11-10 18:50:28 +01:00
vcoppe 1ada5e5d18 New translations poi.mdx (Spanish) 2025-11-10 18:50:27 +01:00
vcoppe f5244c3d93 New translations poi.mdx (French) 2025-11-10 18:50:26 +01:00
vcoppe 1f17776cd4 New translations poi.mdx (Romanian) 2025-11-10 18:50:24 +01:00
vcoppe ebd4f36f94 New translations minify.mdx (Serbian (Latin)) 2025-11-10 18:50:23 +01:00
vcoppe dbb9b5f254 New translations minify.mdx (Chinese Traditional, Hong Kong) 2025-11-10 18:50:22 +01:00
vcoppe 4301472cb2 New translations minify.mdx (Latvian) 2025-11-10 18:50:20 +01:00
vcoppe 15e7954321 New translations minify.mdx (Thai) 2025-11-10 18:50:19 +01:00
vcoppe 32d1de08e9 New translations minify.mdx (Indonesian) 2025-11-10 18:50:18 +01:00
vcoppe ea88663dce New translations minify.mdx (Portuguese, Brazilian) 2025-11-10 18:50:16 +01:00
vcoppe 97aecaf890 New translations minify.mdx (Vietnamese) 2025-11-10 18:50:15 +01:00
vcoppe b16688e1b7 New translations minify.mdx (Chinese Simplified) 2025-11-10 18:50:13 +01:00
vcoppe 5ac856251b New translations minify.mdx (Ukrainian) 2025-11-10 18:50:12 +01:00
vcoppe 62a9aacd85 New translations minify.mdx (Turkish) 2025-11-10 18:50:10 +01:00
vcoppe dff0b55a8c New translations minify.mdx (Swedish) 2025-11-10 18:50:09 +01:00
vcoppe 3b21c75b13 New translations minify.mdx (Russian) 2025-11-10 18:50:08 +01:00
vcoppe 2c7cc4b8e5 New translations minify.mdx (Portuguese) 2025-11-10 18:50:07 +01:00
vcoppe bcf8c0e35c New translations minify.mdx (Polish) 2025-11-10 18:50:05 +01:00
vcoppe 11d2936fca New translations minify.mdx (Norwegian) 2025-11-10 18:50:04 +01:00
vcoppe 5e84429e24 New translations minify.mdx (Dutch) 2025-11-10 18:50:02 +01:00
vcoppe 48c88b2d7e New translations minify.mdx (Lithuanian) 2025-11-10 18:50:00 +01:00
vcoppe e5185c0b77 New translations minify.mdx (Korean) 2025-11-10 18:49:59 +01:00
vcoppe 15773d3aba New translations minify.mdx (Italian) 2025-11-10 18:49:58 +01:00
vcoppe b577837446 New translations minify.mdx (Hungarian) 2025-11-10 18:49:56 +01:00
vcoppe 324e234b2a New translations minify.mdx (Hebrew) 2025-11-10 18:49:55 +01:00
vcoppe d40fefb0ea New translations minify.mdx (Finnish) 2025-11-10 18:49:54 +01:00
vcoppe 7e05568549 New translations minify.mdx (Basque) 2025-11-10 18:49:52 +01:00
vcoppe 0249a52d1c New translations minify.mdx (Greek) 2025-11-10 18:49:50 +01:00
vcoppe 5df1c5b09b New translations minify.mdx (German) 2025-11-10 18:49:49 +01:00
vcoppe 953ec8fe31 New translations minify.mdx (Danish) 2025-11-10 18:49:48 +01:00
vcoppe 6054afebdc New translations minify.mdx (Czech) 2025-11-10 18:49:46 +01:00
vcoppe 04f356e119 New translations minify.mdx (Catalan) 2025-11-10 18:49:45 +01:00
vcoppe a6ebefbb30 New translations minify.mdx (Belarusian) 2025-11-10 18:49:44 +01:00
vcoppe 9a1edbe1fa New translations minify.mdx (Spanish) 2025-11-10 18:49:43 +01:00
vcoppe c46d74be54 New translations minify.mdx (French) 2025-11-10 18:49:42 +01:00
vcoppe 72e949586a New translations minify.mdx (Romanian) 2025-11-10 18:49:41 +01:00
vcoppe 68dacad741 New translations toolbar.mdx (Serbian (Latin)) 2025-11-10 18:48:33 +01:00
vcoppe 7e6505ca73 New translations toolbar.mdx (Chinese Traditional, Hong Kong) 2025-11-10 18:48:32 +01:00
vcoppe 362d83f504 New translations toolbar.mdx (Latvian) 2025-11-10 18:48:31 +01:00
vcoppe e4879736b7 New translations toolbar.mdx (Thai) 2025-11-10 18:48:29 +01:00
vcoppe 68fe6628c7 New translations toolbar.mdx (Indonesian) 2025-11-10 18:48:28 +01:00
vcoppe f39ef569be New translations toolbar.mdx (Portuguese, Brazilian) 2025-11-10 18:48:27 +01:00
vcoppe 42c199376a New translations toolbar.mdx (Vietnamese) 2025-11-10 18:48:26 +01:00
vcoppe cd6b774a8c New translations toolbar.mdx (Chinese Simplified) 2025-11-10 18:48:25 +01:00
vcoppe 62598f94f8 New translations toolbar.mdx (Ukrainian) 2025-11-10 18:48:24 +01:00
vcoppe aa3b46141f New translations toolbar.mdx (Turkish) 2025-11-10 18:48:21 +01:00
vcoppe dcaa2aaeab New translations toolbar.mdx (Swedish) 2025-11-10 18:48:20 +01:00
vcoppe e36f5e47da New translations toolbar.mdx (Russian) 2025-11-10 18:48:19 +01:00
vcoppe 578d7b41b4 New translations toolbar.mdx (Portuguese) 2025-11-10 18:48:18 +01:00
vcoppe c4f81ce279 New translations toolbar.mdx (Polish) 2025-11-10 18:48:16 +01:00
vcoppe 02a7dbea85 New translations toolbar.mdx (Norwegian) 2025-11-10 18:48:15 +01:00
vcoppe 0305d3fe36 New translations toolbar.mdx (Dutch) 2025-11-10 18:48:13 +01:00
vcoppe 84fd034197 New translations toolbar.mdx (Lithuanian) 2025-11-10 18:48:11 +01:00
vcoppe 61b48e2048 New translations toolbar.mdx (Korean) 2025-11-10 18:48:10 +01:00
vcoppe c91f85389c New translations toolbar.mdx (Italian) 2025-11-10 18:48:09 +01:00
vcoppe f3683355a9 New translations toolbar.mdx (Hungarian) 2025-11-10 18:48:08 +01:00
vcoppe a86da886f4 New translations toolbar.mdx (Hebrew) 2025-11-10 18:48:06 +01:00
vcoppe 7bf8d42eb8 New translations toolbar.mdx (Finnish) 2025-11-10 18:48:05 +01:00
vcoppe 88533e29b9 New translations toolbar.mdx (Basque) 2025-11-10 18:48:03 +01:00
vcoppe 8299dcc881 New translations toolbar.mdx (Greek) 2025-11-10 18:48:01 +01:00
vcoppe 0d0c250fea New translations toolbar.mdx (German) 2025-11-10 18:48:00 +01:00
vcoppe eb8d86616b New translations toolbar.mdx (Danish) 2025-11-10 18:47:59 +01:00
vcoppe 1edc90810f New translations toolbar.mdx (Czech) 2025-11-10 18:47:58 +01:00
vcoppe c011d84a35 New translations toolbar.mdx (Catalan) 2025-11-10 18:47:56 +01:00
vcoppe b97f62ac12 New translations toolbar.mdx (Belarusian) 2025-11-10 18:47:55 +01:00
vcoppe 200a38bdc0 New translations toolbar.mdx (Spanish) 2025-11-10 18:47:54 +01:00
vcoppe e12c53a90e New translations toolbar.mdx (French) 2025-11-10 18:47:53 +01:00
vcoppe 7892916f56 New translations toolbar.mdx (Romanian) 2025-11-10 18:47:51 +01:00
vcoppe 5d681beab3 New translations translation.mdx (Serbian (Latin)) 2025-11-10 18:45:10 +01:00
vcoppe a54a2affb3 New translations translation.mdx (Chinese Traditional, Hong Kong) 2025-11-10 18:45:09 +01:00
vcoppe 120062bb85 New translations translation.mdx (Latvian) 2025-11-10 18:45:07 +01:00
vcoppe a0754d2bd7 New translations translation.mdx (Thai) 2025-11-10 18:45:06 +01:00
vcoppe f4b13a84d4 New translations translation.mdx (Indonesian) 2025-11-10 18:45:05 +01:00
vcoppe 1c98e27e4d New translations translation.mdx (Portuguese, Brazilian) 2025-11-10 18:45:04 +01:00
vcoppe 6067e25df8 New translations translation.mdx (Vietnamese) 2025-11-10 18:45:02 +01:00
vcoppe 304c50a247 New translations translation.mdx (Chinese Simplified) 2025-11-10 18:45:00 +01:00
vcoppe 274ad8eac2 New translations translation.mdx (Ukrainian) 2025-11-10 18:44:59 +01:00
vcoppe 9946a3fc0e New translations translation.mdx (Turkish) 2025-11-10 18:44:58 +01:00
vcoppe 622d6db968 New translations translation.mdx (Swedish) 2025-11-10 18:44:56 +01:00
vcoppe d756ce0656 New translations translation.mdx (Russian) 2025-11-10 18:44:55 +01:00
vcoppe 316e776355 New translations translation.mdx (Portuguese) 2025-11-10 18:44:54 +01:00
vcoppe f861b7ad99 New translations translation.mdx (Polish) 2025-11-10 18:44:53 +01:00
vcoppe 01382e98f3 New translations translation.mdx (Norwegian) 2025-11-10 18:44:52 +01:00
vcoppe 3371442bbc New translations translation.mdx (Dutch) 2025-11-10 18:44:51 +01:00
vcoppe a81a804364 New translations translation.mdx (Lithuanian) 2025-11-10 18:44:50 +01:00
vcoppe b24cf5c946 New translations translation.mdx (Korean) 2025-11-10 18:44:48 +01:00
vcoppe 3d60213644 New translations translation.mdx (Italian) 2025-11-10 18:44:47 +01:00
vcoppe a23822c9df New translations translation.mdx (Hungarian) 2025-11-10 18:44:46 +01:00
vcoppe 9fa69758f1 New translations translation.mdx (Hebrew) 2025-11-10 18:44:45 +01:00
vcoppe c892d3f134 New translations translation.mdx (Finnish) 2025-11-10 18:44:44 +01:00
vcoppe 73271501dc New translations translation.mdx (Basque) 2025-11-10 18:44:43 +01:00
vcoppe a62ea520e7 New translations translation.mdx (Greek) 2025-11-10 18:44:42 +01:00
vcoppe a2a0b3c71e New translations translation.mdx (German) 2025-11-10 18:44:40 +01:00
vcoppe 51bedbe003 New translations translation.mdx (Danish) 2025-11-10 18:44:39 +01:00
vcoppe 7062a3e657 New translations translation.mdx (Czech) 2025-11-10 18:44:38 +01:00
vcoppe 209d95d5da New translations translation.mdx (Catalan) 2025-11-10 18:44:37 +01:00
vcoppe 95ee74ab2b New translations translation.mdx (Belarusian) 2025-11-10 18:44:36 +01:00
vcoppe 9f6b268dce New translations translation.mdx (Spanish) 2025-11-10 18:44:35 +01:00
vcoppe 96e8ebcc10 New translations translation.mdx (French) 2025-11-10 18:44:33 +01:00
vcoppe ca261c7037 New translations translation.mdx (Romanian) 2025-11-10 18:44:32 +01:00
vcoppe a95fe13b31 New translations funding.mdx (Serbian (Latin)) 2025-11-10 18:44:09 +01:00
vcoppe c3fe76adf9 New translations funding.mdx (Chinese Traditional, Hong Kong) 2025-11-10 18:44:07 +01:00
vcoppe e9b9d51a6e New translations funding.mdx (Latvian) 2025-11-10 18:44:06 +01:00
vcoppe aff66205e9 New translations funding.mdx (Thai) 2025-11-10 18:44:05 +01:00
vcoppe aa0c9d65ae New translations funding.mdx (Indonesian) 2025-11-10 18:44:04 +01:00
vcoppe 423bd2122f New translations funding.mdx (Portuguese, Brazilian) 2025-11-10 18:44:02 +01:00
vcoppe 7533910114 New translations funding.mdx (Vietnamese) 2025-11-10 18:44:01 +01:00
vcoppe 0d4d377c22 New translations funding.mdx (Chinese Simplified) 2025-11-10 18:43:59 +01:00
vcoppe 3a576b3ea5 New translations funding.mdx (Ukrainian) 2025-11-10 18:43:58 +01:00
vcoppe 27f37744a0 New translations funding.mdx (Turkish) 2025-11-10 18:43:57 +01:00
vcoppe 012ecad0bb New translations funding.mdx (Swedish) 2025-11-10 18:43:55 +01:00
vcoppe 49b5d5e961 New translations funding.mdx (Russian) 2025-11-10 18:43:54 +01:00
vcoppe 01ca48fe96 New translations funding.mdx (Portuguese) 2025-11-10 18:43:52 +01:00
vcoppe 5abe5045b2 New translations funding.mdx (Polish) 2025-11-10 18:43:51 +01:00
vcoppe 8c79a577b9 New translations funding.mdx (Norwegian) 2025-11-10 18:43:50 +01:00
vcoppe 5b18f1016f New translations funding.mdx (Dutch) 2025-11-10 18:43:49 +01:00
vcoppe bda58312bb New translations funding.mdx (Lithuanian) 2025-11-10 18:43:47 +01:00
vcoppe eff352a003 New translations funding.mdx (Korean) 2025-11-10 18:43:46 +01:00
vcoppe 3109d4ff82 New translations funding.mdx (Italian) 2025-11-10 18:43:45 +01:00
vcoppe c44eda3af6 New translations funding.mdx (Hungarian) 2025-11-10 18:43:44 +01:00
vcoppe 33e8c41c6f New translations funding.mdx (Hebrew) 2025-11-10 18:43:43 +01:00
vcoppe e0b515ba2b New translations funding.mdx (Finnish) 2025-11-10 18:43:42 +01:00
vcoppe c6b6ad41fb New translations funding.mdx (Basque) 2025-11-10 18:43:40 +01:00
vcoppe 99576238cd New translations funding.mdx (Greek) 2025-11-10 18:43:39 +01:00
vcoppe db712650ef New translations funding.mdx (German) 2025-11-10 18:43:38 +01:00
vcoppe d77e7b07eb New translations funding.mdx (Danish) 2025-11-10 18:43:37 +01:00
vcoppe 47b35fad3f New translations funding.mdx (Czech) 2025-11-10 18:43:36 +01:00
vcoppe 720105ca92 New translations funding.mdx (Catalan) 2025-11-10 18:43:35 +01:00
vcoppe ce656068e3 New translations funding.mdx (Belarusian) 2025-11-10 18:43:34 +01:00
vcoppe e69ba76e7d New translations funding.mdx (Spanish) 2025-11-10 18:43:32 +01:00
vcoppe d1bab213a3 New translations funding.mdx (French) 2025-11-10 18:43:31 +01:00
vcoppe 016a9341ad New translations funding.mdx (Romanian) 2025-11-10 18:43:30 +01:00
vcoppe 81f407ad5f New translations files-and-stats.mdx (Serbian (Latin)) 2025-11-10 18:42:44 +01:00
vcoppe 37ff2f6bdb New translations files-and-stats.mdx (Chinese Traditional, Hong Kong) 2025-11-10 18:42:43 +01:00
vcoppe 13cf1a9551 New translations files-and-stats.mdx (Latvian) 2025-11-10 18:42:41 +01:00
vcoppe 799aaac449 New translations files-and-stats.mdx (Thai) 2025-11-10 18:42:40 +01:00
vcoppe fbe430e4cf New translations files-and-stats.mdx (Indonesian) 2025-11-10 18:42:39 +01:00
vcoppe 511746620e New translations files-and-stats.mdx (Portuguese, Brazilian) 2025-11-10 18:42:38 +01:00
vcoppe fed5b648f8 New translations files-and-stats.mdx (Vietnamese) 2025-11-10 18:42:37 +01:00
vcoppe 1f601743f6 New translations files-and-stats.mdx (Chinese Simplified) 2025-11-10 18:42:36 +01:00
vcoppe db3ccf5da7 New translations files-and-stats.mdx (Ukrainian) 2025-11-10 18:42:34 +01:00
vcoppe 038898d580 New translations files-and-stats.mdx (Turkish) 2025-11-10 18:42:33 +01:00
vcoppe dafbc6bc51 New translations files-and-stats.mdx (Swedish) 2025-11-10 18:42:32 +01:00
vcoppe 3d8271e7a1 New translations files-and-stats.mdx (Russian) 2025-11-10 18:42:31 +01:00
vcoppe e2ade95219 New translations files-and-stats.mdx (Portuguese) 2025-11-10 18:42:30 +01:00
vcoppe 604971a576 New translations files-and-stats.mdx (Polish) 2025-11-10 18:42:28 +01:00
vcoppe 76173103af New translations files-and-stats.mdx (Norwegian) 2025-11-10 18:42:27 +01:00
vcoppe 3fcbc60232 New translations routing.mdx (Czech) 2025-11-10 18:42:26 +01:00
vcoppe 835bdb39d0 New translations files-and-stats.mdx (Dutch) 2025-11-10 18:42:25 +01:00
vcoppe 200f09c5d2 New translations files-and-stats.mdx (Lithuanian) 2025-11-10 18:42:23 +01:00
vcoppe 99a008f122 New translations files-and-stats.mdx (Korean) 2025-11-10 18:42:22 +01:00
vcoppe 9b71e89ba0 New translations files-and-stats.mdx (Italian) 2025-11-10 18:42:20 +01:00
vcoppe 9955369cfa New translations files-and-stats.mdx (Hungarian) 2025-11-10 18:42:18 +01:00
vcoppe 6ef6ffab66 New translations files-and-stats.mdx (Hebrew) 2025-11-10 18:42:17 +01:00
vcoppe 0dd46f0f20 New translations files-and-stats.mdx (Finnish) 2025-11-10 18:42:15 +01:00
vcoppe cce0d85dd4 New translations files-and-stats.mdx (Basque) 2025-11-10 18:42:14 +01:00
vcoppe 1ed3eae038 New translations files-and-stats.mdx (Greek) 2025-11-10 18:42:12 +01:00
vcoppe 2854c24197 New translations files-and-stats.mdx (German) 2025-11-10 18:42:10 +01:00
vcoppe 4b90ddda2a New translations files-and-stats.mdx (Danish) 2025-11-10 18:42:09 +01:00
vcoppe 552c39e583 New translations files-and-stats.mdx (Czech) 2025-11-10 18:42:08 +01:00
vcoppe 5176e459b5 New translations files-and-stats.mdx (Catalan) 2025-11-10 18:42:06 +01:00
vcoppe 39515d0600 New translations files-and-stats.mdx (Belarusian) 2025-11-10 18:42:05 +01:00
vcoppe 5314949394 New translations files-and-stats.mdx (Spanish) 2025-11-10 18:42:02 +01:00
vcoppe 59e5be749c New translations files-and-stats.mdx (French) 2025-11-10 18:42:01 +01:00
vcoppe da252a0070 New translations files-and-stats.mdx (Romanian) 2025-11-10 18:42:00 +01:00
vcoppe b6199e430c New translations en.json (Serbian (Latin)) 2025-11-10 18:41:58 +01:00
vcoppe e260a68c26 New translations en.json (Chinese Traditional, Hong Kong) 2025-11-10 18:41:57 +01:00
vcoppe 570cb2deaf New translations en.json (Latvian) 2025-11-10 18:41:55 +01:00
vcoppe 7a36f03fb5 New translations en.json (Thai) 2025-11-10 18:41:54 +01:00
vcoppe 6c3058ba97 New translations en.json (Indonesian) 2025-11-10 18:41:53 +01:00
vcoppe 48a1034c12 New translations en.json (Portuguese, Brazilian) 2025-11-10 18:41:52 +01:00
vcoppe aaddb50ab9 New translations en.json (Vietnamese) 2025-11-10 18:41:51 +01:00
vcoppe a947586cfe New translations en.json (Chinese Simplified) 2025-11-10 18:41:49 +01:00
vcoppe f3cfa14a59 New translations en.json (Ukrainian) 2025-11-10 18:41:48 +01:00
vcoppe a2c0a77c53 New translations en.json (Turkish) 2025-11-10 18:41:47 +01:00
vcoppe c078f9d5cb New translations en.json (Swedish) 2025-11-10 18:41:46 +01:00
vcoppe 08eab8a157 New translations en.json (Russian) 2025-11-10 18:41:45 +01:00
vcoppe 9c36f234bc New translations en.json (Portuguese) 2025-11-10 18:41:44 +01:00
vcoppe 36b81d0e2a New translations en.json (Polish) 2025-11-10 18:41:42 +01:00
vcoppe 4432c14377 New translations en.json (Norwegian) 2025-11-10 18:41:41 +01:00
vcoppe 99e6dd5ca3 New translations en.json (Dutch) 2025-11-10 18:41:40 +01:00
vcoppe 6327a25aec New translations en.json (Lithuanian) 2025-11-10 18:41:39 +01:00
vcoppe 40989de7f5 New translations en.json (Korean) 2025-11-10 18:41:38 +01:00
vcoppe 58af44e795 New translations en.json (Italian) 2025-11-10 18:41:36 +01:00
vcoppe 4910cc05f8 New translations en.json (Hungarian) 2025-11-10 18:41:35 +01:00
vcoppe 164ee24d16 New translations en.json (Hebrew) 2025-11-10 18:41:34 +01:00
vcoppe 0ffda4ab7c New translations en.json (Finnish) 2025-11-10 18:41:33 +01:00
vcoppe 4694a6271d New translations en.json (Basque) 2025-11-10 18:41:32 +01:00
vcoppe 2f50bc747a New translations en.json (Greek) 2025-11-10 18:41:30 +01:00
vcoppe a13f621a81 New translations en.json (German) 2025-11-10 18:41:29 +01:00
vcoppe 0cc520cc67 New translations en.json (Czech) 2025-11-10 18:41:28 +01:00
vcoppe c9451c3f2d New translations en.json (Catalan) 2025-11-10 18:41:26 +01:00
vcoppe 8da53ffda2 New translations en.json (Belarusian) 2025-11-10 18:41:25 +01:00
vcoppe 4319761687 New translations en.json (Spanish) 2025-11-10 18:41:24 +01:00
vcoppe a1f3227cd9 New translations en.json (Romanian) 2025-11-10 18:41:22 +01:00
vcoppe b07f87c920 New translations en.json (Danish) 2025-11-10 18:41:21 +01:00
vcoppe 9c8f23eb64 New translations en.json (French) 2025-11-10 18:41:19 +01:00
vcoppe 2d232b3c4b New translations routing.mdx (Czech) 2025-11-09 23:00:54 +01:00
vcoppe 712dc9bb34 New translations en.json (Danish) 2025-11-04 06:53:16 +01:00
vcoppe 5c338d53ae New translations en.json (Danish) 2025-11-04 05:49:17 +01:00
vcoppe 8d26842aab New translations en.json (Russian) 2025-10-21 12:25:32 +02:00
vcoppe 76e654304b New translations en.json (Russian) 2025-10-21 10:35:36 +02:00
vcoppe 32ba679719 New translations en.json (Korean) 2025-10-17 09:13:10 +02:00
vcoppe cac0fefcdb New translations en.json (Chinese Traditional, Hong Kong) 2025-10-14 02:30:52 +02:00
vcoppe 498c76dd96 New translations en.json (Chinese Simplified) 2025-10-14 02:30:51 +02:00
vcoppe 7526182304 New translations en.json (Romanian) 2025-10-12 09:39:34 +02:00
vcoppe d46bbd9cbf New translations en.json (Romanian) 2025-10-12 08:41:16 +02:00
vcoppe e98b537499 New translations en.json (Ukrainian) 2025-10-09 12:12:30 +02:00
vcoppe fc9d8509e5 New translations en.json (Ukrainian) 2025-10-09 10:21:48 +02:00
vcoppe 7c6bbb61b5 New translations en.json (Ukrainian) 2025-10-08 21:12:54 +02:00
vcoppe 8501ddc87f New translations faq.mdx (Polish) 2025-10-07 19:48:49 +02:00
vcoppe 7d9b94525e New translations time.mdx (Polish) 2025-10-07 19:48:48 +02:00
vcoppe eb02f0eadf New translations scissors.mdx (Polish) 2025-10-07 19:48:47 +02:00
vcoppe 69a8ba5aec New translations routing.mdx (Polish) 2025-10-07 19:48:46 +02:00
vcoppe fe49b8e618 New translations merge.mdx (Polish) 2025-10-07 19:48:45 +02:00
vcoppe 26bf4dde5f New translations extract.mdx (Polish) 2025-10-07 19:48:44 +02:00
vcoppe e9b73050ba New translations toolbar.mdx (Polish) 2025-10-07 19:48:43 +02:00
vcoppe bacd0ab43f New translations view.mdx (Polish) 2025-10-07 19:48:42 +02:00
vcoppe e438051371 New translations file.mdx (Polish) 2025-10-07 19:48:40 +02:00
vcoppe 314155593d New translations edit.mdx (Polish) 2025-10-07 19:48:39 +02:00
vcoppe 787f819ce0 New translations menu.mdx (Polish) 2025-10-07 19:48:38 +02:00
vcoppe 3632a62ea3 New translations integration.mdx (Polish) 2025-10-07 19:48:37 +02:00
vcoppe c7294df007 New translations gpx.mdx (Polish) 2025-10-07 19:48:36 +02:00
vcoppe e3ad7fe3c0 New translations getting-started.mdx (Polish) 2025-10-07 19:48:34 +02:00
vcoppe 6213683ddf New translations files-and-stats.mdx (Polish) 2025-10-07 19:48:33 +02:00
vcoppe a4ddfc9970 New translations en.json (Polish) 2025-10-07 19:48:32 +02:00
vcoppe 7ff271f9b9 New translations view.mdx (Spanish) 2025-10-07 02:54:04 +02:00
vcoppe d75cdd63a9 New translations file.mdx (Spanish) 2025-10-07 02:54:02 +02:00
vcoppe 0a7575d1e4 New translations integration.mdx (Spanish) 2025-10-07 02:54:01 +02:00
vcoppe ec3022d8ad New translations en.json (Spanish) 2025-10-07 01:50:49 +02:00
vcoppe d42103b91b New translations en.json (Ukrainian) 2025-10-03 23:57:07 +02:00
vcoppe 00f7d08b04 New translations en.json (Ukrainian) 2025-10-03 22:36:21 +02:00
vcoppe 408cc383cb New translations en.json (Portuguese) 2025-10-03 17:56:00 +02:00
vcoppe 5c926d0ac6 New translations en.json (Ukrainian) 2025-09-23 18:44:01 +02:00
vcoppe 5cb88782fc New translations en.json (Ukrainian) 2025-09-23 15:59:34 +02:00
vcoppe 5eef4e9ece New translations en.json (Russian) 2025-09-20 17:13:17 +02:00
vcoppe 04a2124141 New translations en.json (Italian) 2025-09-20 17:13:15 +02:00
vcoppe 1b6229b2a1 New translations elevation.mdx (Italian) 2025-09-20 16:10:28 +02:00
vcoppe bca6db50a7 New translations en.json (Italian) 2025-09-20 16:10:27 +02:00
vcoppe f3aae26996 New translations settings.mdx (Chinese Simplified) 2025-09-10 10:34:12 +02:00
vcoppe f3c17a8e0f New translations en.json (Indonesian) 2025-09-05 04:13:37 +02:00
vcoppe d6b24f8753 New translations en.json (Indonesian) 2025-09-05 03:11:41 +02:00
vcoppe 253db0a303 New translations en.json (Norwegian) 2025-09-04 18:13:32 +02:00
vcoppe 8499e52461 New translations en.json (Dutch) 2025-09-01 08:57:32 +02:00
vcoppe d0153179a9 New translations en.json (Indonesian) 2025-08-29 18:50:34 +02:00
vcoppe 264d03727e New translations edit.mdx (Chinese Traditional, Hong Kong) 2025-08-13 18:27:34 +02:00
vcoppe 544405d9b9 New translations en.json (Chinese Traditional, Hong Kong) 2025-08-13 16:32:09 +02:00
vcoppe 24488a3b67 New translations en.json (Indonesian) 2025-08-08 14:15:35 +02:00
vcoppe ae78185b29 New translations en.json (Indonesian) 2025-08-08 12:50:22 +02:00
vcoppe 7f682b24ef New translations en.json (Indonesian) 2025-08-04 04:32:52 +02:00
vcoppe d42a52d8cf New translations en.json (Norwegian) 2025-08-03 16:13:45 +02:00
vcoppe b85df15890 New translations en.json (Norwegian) 2025-08-03 15:00:56 +02:00
vcoppe 393499f34f New translations en.json (Indonesian) 2025-08-02 13:58:57 +02:00
vcoppe c656d0f9b5 New translations en.json (Indonesian) 2025-08-02 12:40:02 +02:00
vcoppe 32017a8859 New translations en.json (Indonesian) 2025-08-02 11:35:56 +02:00
vcoppe d87c5b1140 New translations en.json (Norwegian) 2025-08-01 22:28:15 +02:00
vcoppe f59f783d3f New translations en.json (Norwegian) 2025-08-01 21:18:36 +02:00
vcoppe ec298eac61 New translations elevation.mdx (Indonesian) 2025-08-01 16:14:37 +02:00
vcoppe 81a25bb4ee New translations faq.mdx (Indonesian) 2025-08-01 16:14:36 +02:00
vcoppe e99f044e45 New translations time.mdx (Indonesian) 2025-08-01 16:14:35 +02:00
vcoppe 5ae25a5fd9 New translations scissors.mdx (Indonesian) 2025-08-01 16:14:34 +02:00
vcoppe e9d1cb4907 New translations routing.mdx (Indonesian) 2025-08-01 16:14:33 +02:00
vcoppe 99f8ca2dca New translations poi.mdx (Indonesian) 2025-08-01 16:14:31 +02:00
vcoppe ddea5d38b5 New translations minify.mdx (Indonesian) 2025-08-01 16:14:30 +02:00
vcoppe 31d2b83550 New translations merge.mdx (Indonesian) 2025-08-01 16:14:29 +02:00
vcoppe 5535e56ed2 New translations extract.mdx (Indonesian) 2025-08-01 16:14:28 +02:00
vcoppe d740b95dbc New translations clean.mdx (Indonesian) 2025-08-01 16:14:26 +02:00
vcoppe ae92e9a945 New translations toolbar.mdx (Indonesian) 2025-08-01 16:14:25 +02:00
vcoppe 29730c3896 New translations view.mdx (Indonesian) 2025-08-01 16:14:24 +02:00
vcoppe a5ae8270f0 New translations settings.mdx (Indonesian) 2025-08-01 16:14:23 +02:00
vcoppe 54f5fa6432 New translations file.mdx (Indonesian) 2025-08-01 16:14:22 +02:00
vcoppe 0260644063 New translations edit.mdx (Indonesian) 2025-08-01 16:14:20 +02:00
vcoppe 267fc03a82 New translations menu.mdx (Indonesian) 2025-08-01 16:14:19 +02:00
vcoppe bf1537584c New translations map-controls.mdx (Indonesian) 2025-08-01 16:14:18 +02:00
vcoppe 9ee7825022 New translations integration.mdx (Indonesian) 2025-08-01 16:14:17 +02:00
vcoppe 2be0c42dd1 New translations translation.mdx (Indonesian) 2025-08-01 16:14:16 +02:00
vcoppe 3423c053a2 New translations mapbox.mdx (Indonesian) 2025-08-01 16:14:15 +02:00
vcoppe 26923cca00 New translations funding.mdx (Indonesian) 2025-08-01 16:14:14 +02:00
vcoppe 36e027659c New translations gpx.mdx (Indonesian) 2025-08-01 16:14:13 +02:00
vcoppe f447dccdb4 New translations getting-started.mdx (Indonesian) 2025-08-01 16:14:11 +02:00
vcoppe 69eae32851 New translations files-and-stats.mdx (Indonesian) 2025-08-01 16:14:10 +02:00
vcoppe aa2fcfb8cb New translations en.json (Indonesian) 2025-08-01 16:14:09 +02:00
vcoppe fae5ef2a41 New translations en.json (Norwegian) 2025-07-31 23:43:31 +02:00
vcoppe 7251ca7d2d New translations toolbar.mdx (Norwegian) 2025-07-31 22:34:29 +02:00
vcoppe 7cdbd919bf New translations en.json (Norwegian) 2025-07-31 22:34:27 +02:00
vcoppe d450f95602 New translations en.json (Dutch) 2025-07-31 14:26:59 +02:00
vcoppe 5a65201971 New translations en.json (Thai) 2025-07-30 18:35:07 +02:00
vcoppe d303b8db3e New translations gpx.mdx (Portuguese) 2025-07-20 19:33:06 +02:00
vcoppe 06baa33827 New translations gpx.mdx (Portuguese) 2025-07-20 18:31:13 +02:00
vcoppe 42743e637e New translations en.json (French) 2025-07-18 16:38:10 +02:00
vcoppe 9969fd7dec New translations edit.mdx (Swedish) 2025-07-17 23:06:28 +02:00
vcoppe fc6d5c2a1d New translations en.json (Basque) 2025-07-16 07:51:58 +02:00
vcoppe f8abb1ca24 New translations elevation.mdx (Thai) 2025-07-15 14:10:56 +02:00
vcoppe a5af38ae3d New translations faq.mdx (Thai) 2025-07-15 14:10:55 +02:00
vcoppe aab70951dc New translations time.mdx (Thai) 2025-07-15 14:10:54 +02:00
vcoppe 334cacf93c New translations scissors.mdx (Thai) 2025-07-15 14:10:52 +02:00
vcoppe 53024012fc New translations routing.mdx (Thai) 2025-07-15 14:10:51 +02:00
vcoppe 86a72f77c1 New translations poi.mdx (Thai) 2025-07-15 14:10:50 +02:00
vcoppe bc11a5ad0a New translations minify.mdx (Thai) 2025-07-15 14:10:49 +02:00
vcoppe 8f2d217fd4 New translations merge.mdx (Thai) 2025-07-15 14:10:47 +02:00
vcoppe 183727cd50 New translations extract.mdx (Thai) 2025-07-15 14:10:46 +02:00
vcoppe 676e87591a New translations clean.mdx (Thai) 2025-07-15 14:10:44 +02:00
vcoppe 8c05fc4da0 New translations toolbar.mdx (Thai) 2025-07-15 14:10:43 +02:00
vcoppe 2bab06561e New translations view.mdx (Thai) 2025-07-15 14:10:42 +02:00
vcoppe dfa7e2f5bb New translations settings.mdx (Thai) 2025-07-15 14:10:41 +02:00
vcoppe 78bece5616 New translations file.mdx (Thai) 2025-07-15 14:10:39 +02:00
vcoppe eeea15e373 New translations edit.mdx (Thai) 2025-07-15 14:10:38 +02:00
vcoppe 80cd513ab7 New translations menu.mdx (Thai) 2025-07-15 14:10:37 +02:00
vcoppe 942ef1615e New translations map-controls.mdx (Thai) 2025-07-15 14:10:35 +02:00
vcoppe a354698022 New translations integration.mdx (Thai) 2025-07-15 14:10:34 +02:00
vcoppe 0cdea488c9 New translations translation.mdx (Thai) 2025-07-15 14:10:33 +02:00
vcoppe 4f4291ac47 New translations mapbox.mdx (Thai) 2025-07-15 14:10:32 +02:00
vcoppe bf0cf03091 New translations funding.mdx (Thai) 2025-07-15 14:10:30 +02:00
vcoppe f7da09f20f New translations gpx.mdx (Thai) 2025-07-15 14:10:28 +02:00
vcoppe be1529331c New translations getting-started.mdx (Thai) 2025-07-15 14:10:27 +02:00
vcoppe 301d658a29 New translations files-and-stats.mdx (Thai) 2025-07-15 14:10:26 +02:00
vcoppe 1cc54e5b2c New translations en.json (Thai) 2025-07-15 14:10:25 +02:00
vcoppe 65a7fd21e7 New translations en.json (Italian) 2025-07-14 12:56:13 +02:00
vcoppe 856537c0cd New translations en.json (Ukrainian) 2025-07-10 01:33:15 +02:00
vcoppe b2a88e0063 New translations en.json (Ukrainian) 2025-07-10 00:32:33 +02:00
vcoppe 85a7068785 New translations en.json (Ukrainian) 2025-07-09 12:14:44 +02:00
vcoppe cbb733d99a New translations settings.mdx (Ukrainian) 2025-07-07 18:53:29 +02:00
vcoppe ce88c94a19 New translations edit.mdx (Ukrainian) 2025-07-07 18:53:28 +02:00
vcoppe 16516915d8 New translations translation.mdx (Ukrainian) 2025-07-07 18:53:27 +02:00
vcoppe 6addb8da23 New translations mapbox.mdx (Ukrainian) 2025-07-07 18:53:26 +02:00
vcoppe bc7f664fd8 New translations funding.mdx (Ukrainian) 2025-07-07 17:28:09 +02:00
vcoppe aac17aa33c New translations en.json (Ukrainian) 2025-07-07 17:28:08 +02:00
vcoppe 825500e207 New translations en.json (Ukrainian) 2025-07-07 15:46:23 +02:00
vcoppe 4d42016c72 New translations en.json (Italian) 2025-06-28 14:49:34 +02:00
vcoppe 9d665df602 New translations poi.mdx (Polish) 2025-06-23 23:44:31 +02:00
vcoppe 9087f69fb0 New translations minify.mdx (Polish) 2025-06-23 23:44:30 +02:00
vcoppe 2a06f6a214 New translations clean.mdx (Polish) 2025-06-23 23:44:29 +02:00
vcoppe 78a8428bd0 New translations toolbar.mdx (Polish) 2025-06-23 23:44:28 +02:00
vcoppe 0d235768fa New translations menu.mdx (Polish) 2025-06-23 23:44:26 +02:00
vcoppe af092bbdec New translations edit.mdx (Polish) 2025-06-23 22:23:55 +02:00
vcoppe 4961630d62 New translations en.json (Chinese Traditional, Hong Kong) 2025-06-20 05:53:38 +02:00
vcoppe 81920b9ab9 New translations en.json (Chinese Traditional, Hong Kong) 2025-06-20 04:39:50 +02:00
vcoppe 9e031d3b5b New translations en.json (Chinese Traditional, Hong Kong) 2025-06-20 03:33:14 +02:00
vcoppe 7ae3ed6d2a New translations elevation.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:44 +02:00
vcoppe 05d79f2b51 New translations faq.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:43 +02:00
vcoppe 274e591354 New translations time.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:42 +02:00
vcoppe 95fd152b3d New translations scissors.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:41 +02:00
vcoppe ffc91ed6d8 New translations routing.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:40 +02:00
vcoppe de0b759875 New translations poi.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:38 +02:00
vcoppe f041dcf944 New translations minify.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:37 +02:00
vcoppe 946b9bd9d1 New translations merge.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:36 +02:00
vcoppe db77a69838 New translations extract.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:35 +02:00
vcoppe d10f4d26e2 New translations clean.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:34 +02:00
vcoppe 6b62d686ba New translations toolbar.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:33 +02:00
vcoppe 065826e64d New translations view.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:32 +02:00
vcoppe a3b096343f New translations settings.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:31 +02:00
vcoppe b33be91b06 New translations file.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:29 +02:00
vcoppe a94a1816c5 New translations edit.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:28 +02:00
vcoppe 9a9e7fea07 New translations menu.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:27 +02:00
vcoppe 9a03042077 New translations map-controls.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:26 +02:00
vcoppe 704d3b2d6b New translations integration.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:24 +02:00
vcoppe e5c2be238d New translations translation.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:23 +02:00
vcoppe 9feea07527 New translations mapbox.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:22 +02:00
vcoppe b0967d03b8 New translations funding.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:21 +02:00
vcoppe d33fd71f93 New translations gpx.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:20 +02:00
vcoppe 226b5b2682 New translations getting-started.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:18 +02:00
vcoppe f8879b0223 New translations files-and-stats.mdx (Chinese Traditional, Hong Kong) 2025-06-19 18:26:17 +02:00
vcoppe ada09d96c4 New translations en.json (Chinese Traditional, Hong Kong) 2025-06-19 18:26:16 +02:00
361 changed files with 11004 additions and 5403 deletions
+5 -5
View File
@@ -8,12 +8,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: 20 node-version: 24
cache: npm cache: npm
cache-dependency-path: | cache-dependency-path: |
gpx/package-lock.json gpx/package-lock.json
@@ -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
@@ -41,7 +41,7 @@ jobs:
npm run build --prefix website npm run build --prefix website
- name: Upload Artifacts - name: Upload Artifacts
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@v4
with: with:
path: 'website/build/' path: 'website/build/'
+3 -6
View File
@@ -1,6 +1,3 @@
# Ignore files for PNPM, NPM and YARN website/src/lib/components/ui
pnpm-lock.yaml website/src/lib/docs/**/*.mdx
package-lock.json **/*.webmanifest
yarn.lock
src/lib/components/ui
*.mdx
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2024 gpx.studio Copyright (c) 2026 gpx.studio
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+4 -4
View File
@@ -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 maps
- [brouter](https://github.com/abrensch/brouter) — routing engine - [brouter](https://github.com/abrensch/brouter) — 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
+2 -2
View File
@@ -25,7 +25,7 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"postinstall": "npm run build", "postinstall": "npm run build",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . --config ../.prettierrc && eslint .",
"format": "prettier --write ." "format": "prettier --write . --config ../.prettierrc"
} }
} }
+216 -463
View File
@@ -1,4 +1,5 @@
import { ramerDouglasPeucker } from './simplify'; import { ramerDouglasPeucker } from './simplify';
import { GPXStatistics, GPXStatisticsGroup, TrackPointLocalStatistics } from './statistics';
import { import {
Coordinates, Coordinates,
GPXFileAttributes, GPXFileAttributes,
@@ -17,6 +18,9 @@ import {
import { immerable, isDraft, original, freeze } from 'immer'; import { immerable, isDraft, original, freeze } from 'immer';
function cloneJSON<T>(obj: T): T { function cloneJSON<T>(obj: T): T {
if (obj === undefined) {
return undefined;
}
if (obj === null || typeof obj !== 'object') { if (obj === null || typeof obj !== 'object') {
return null; return null;
} }
@@ -33,7 +37,6 @@ export abstract class GPXTreeElement<T extends GPXTreeElement<any>> {
abstract getNumberOfTrackPoints(): number; abstract getNumberOfTrackPoints(): number;
abstract getStartTimestamp(): Date | undefined; abstract getStartTimestamp(): Date | undefined;
abstract getEndTimestamp(): Date | undefined; abstract getEndTimestamp(): Date | undefined;
abstract getStatistics(): GPXStatistics;
abstract getSegments(): TrackSegment[]; abstract getSegments(): TrackSegment[];
abstract getTrackPoints(): TrackPoint[]; abstract getTrackPoints(): TrackPoint[];
@@ -73,14 +76,6 @@ abstract class GPXTreeNode<T extends GPXTreeElement<any>> extends GPXTreeElement
return this.children[this.children.length - 1].getEndTimestamp(); return this.children[this.children.length - 1].getEndTimestamp();
} }
getStatistics(): GPXStatistics {
let statistics = new GPXStatistics();
for (let child of this.children) {
statistics.mergeWith(child.getStatistics());
}
return statistics;
}
getSegments(): TrackSegment[] { getSegments(): TrackSegment[] {
return this.children.flatMap((child) => child.getSegments()); return this.children.flatMap((child) => child.getSegments());
} }
@@ -145,7 +140,9 @@ export class GPXFile extends GPXTreeNode<Track> {
}, },
}, },
}; };
this.wpt = gpx.wpt ? gpx.wpt.map((waypoint) => new Waypoint(waypoint)) : []; this.wpt = gpx.wpt
? gpx.wpt.map((waypoint, index) => new Waypoint(waypoint, index))
: [];
this.trk = gpx.trk ? gpx.trk.map((track) => new Track(track)) : []; this.trk = gpx.trk ? gpx.trk.map((track) => new Track(track)) : [];
if (gpx.rte && gpx.rte.length > 0) { if (gpx.rte && gpx.rte.length > 0) {
this.trk = this.trk.concat(gpx.rte.map((route) => convertRouteToTrack(route))); this.trk = this.trk.concat(gpx.rte.map((route) => convertRouteToTrack(route)));
@@ -183,9 +180,6 @@ export class GPXFile extends GPXTreeNode<Track> {
segment._data['segmentIndex'] = segmentIndex; segment._data['segmentIndex'] = segmentIndex;
}); });
}); });
this.wpt.forEach((waypoint, waypointIndex) => {
waypoint._data['index'] = waypointIndex;
});
} }
get children(): Array<Track> { get children(): Array<Track> {
@@ -206,8 +200,16 @@ export class GPXFile extends GPXTreeNode<Track> {
}); });
} }
getStatistics(): GPXStatisticsGroup {
let statistics = new GPXStatisticsGroup();
this.forEachSegment((segment) => {
statistics.add(segment.getStatistics());
});
return statistics;
}
getStyle(defaultColor?: string): MergedLineStyles { getStyle(defaultColor?: string): MergedLineStyles {
return this.trk const style = this.trk
.map((track) => track.getStyle()) .map((track) => track.getStyle())
.reduce( .reduce(
(acc, style) => { (acc, style) => {
@@ -217,8 +219,6 @@ export class GPXFile extends GPXTreeNode<Track> {
!acc.color.includes(style['gpx_style:color']) !acc.color.includes(style['gpx_style:color'])
) { ) {
acc.color.push(style['gpx_style:color']); acc.color.push(style['gpx_style:color']);
} else if (defaultColor && !acc.color.includes(defaultColor)) {
acc.color.push(defaultColor);
} }
if ( if (
style && style &&
@@ -242,6 +242,10 @@ export class GPXFile extends GPXTreeNode<Track> {
width: [], width: [],
} }
); );
if (style.color.length === 0 && defaultColor) {
style.color.push(defaultColor);
}
return style;
} }
clone(): GPXFile { clone(): GPXFile {
@@ -804,7 +808,7 @@ export class TrackSegment extends GPXTreeLeaf {
constructor(segment?: (TrackSegmentType & { _data?: any }) | TrackSegment) { constructor(segment?: (TrackSegmentType & { _data?: any }) | TrackSegment) {
super(); super();
if (segment) { if (segment) {
this.trkpt = segment.trkpt.map((point) => new TrackPoint(point)); this.trkpt = segment.trkpt.map((point, index) => new TrackPoint(point, index));
if (segment.hasOwnProperty('_data')) { if (segment.hasOwnProperty('_data')) {
this._data = segment._data; this._data = segment._data;
} }
@@ -816,15 +820,12 @@ export class TrackSegment extends GPXTreeLeaf {
_computeStatistics(): GPXStatistics { _computeStatistics(): GPXStatistics {
let statistics = new GPXStatistics(); let statistics = new GPXStatistics();
statistics.local.points = this.trkpt.map((point) => point); statistics.global.length = this.trkpt.length;
statistics.local.points = this.trkpt.slice(0);
statistics.local.elevation.smoothed = this._computeSmoothedElevation(); statistics.local.data = this.trkpt.map(() => new TrackPointLocalStatistics());
statistics.local.slope.at = this._computeSlope();
const points = this.trkpt; const points = this.trkpt;
for (let i = 0; i < points.length; i++) { for (let i = 0; i < points.length; i++) {
points[i]._data['index'] = i;
// distance // distance
let dist = 0; let dist = 0;
if (i > 0) { if (i > 0) {
@@ -833,34 +834,18 @@ export class TrackSegment extends GPXTreeLeaf {
statistics.global.distance.total += dist; statistics.global.distance.total += dist;
} }
statistics.local.distance.total.push(statistics.global.distance.total); statistics.local.data[i].distance.total = statistics.global.distance.total;
// elevation
if (i > 0) {
const ele =
statistics.local.elevation.smoothed[i] -
statistics.local.elevation.smoothed[i - 1];
if (ele > 0) {
statistics.global.elevation.gain += ele;
} else if (ele < 0) {
statistics.global.elevation.loss -= ele;
}
}
statistics.local.elevation.gain.push(statistics.global.elevation.gain);
statistics.local.elevation.loss.push(statistics.global.elevation.loss);
// time // time
if (points[i].time === undefined) { if (points[i].time === undefined) {
statistics.local.time.total.push(0); statistics.local.data[i].time.total = 0;
} else { } else {
if (statistics.global.time.start === undefined) { if (statistics.global.time.start === undefined) {
statistics.global.time.start = points[i].time; statistics.global.time.start = points[i].time;
} }
statistics.global.time.end = points[i].time; statistics.global.time.end = points[i].time;
statistics.local.time.total.push( statistics.local.data[i].time.total =
(points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000 (points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000;
);
} }
// speed // speed
@@ -875,8 +860,8 @@ export class TrackSegment extends GPXTreeLeaf {
} }
} }
statistics.local.distance.moving.push(statistics.global.distance.moving); statistics.local.data[i].distance.moving = statistics.global.distance.moving;
statistics.local.time.moving.push(statistics.global.time.moving); statistics.local.data[i].time.moving = statistics.global.time.moving;
// bounds // bounds
statistics.global.bounds.southWest.lat = Math.min( statistics.global.bounds.southWest.lat = Math.min(
@@ -960,8 +945,7 @@ export class TrackSegment extends GPXTreeLeaf {
} }
} }
[statistics.local.slope.segment, statistics.local.slope.length] = this._elevationComputation(statistics);
this._computeSlopeSegments(statistics);
statistics.global.time.total = statistics.global.time.total =
statistics.global.time.start && statistics.global.time.end statistics.global.time.start && statistics.global.time.end
@@ -977,73 +961,115 @@ export class TrackSegment extends GPXTreeLeaf {
? statistics.global.distance.moving / (statistics.global.time.moving / 3600) ? statistics.global.distance.moving / (statistics.global.time.moving / 3600)
: 0; : 0;
statistics.local.speed = distanceWindowSmoothingWithDistanceAccumulator( timeWindowSmoothing(
points, points,
200, 10000,
(accumulated, start, end) => (start, end) =>
points[start].time && points[end].time points[start].time && points[end].time
? (3600 * accumulated) / ? (3600 *
(points[end].time.getTime() - points[start].time.getTime()) (statistics.local.data[end].distance.total -
: undefined statistics.local.data[start].distance.total)) /
Math.max(
(points[end].time.getTime() - points[start].time.getTime()) / 1000,
1
)
: undefined,
(value, index) => {
statistics.local.data[index].speed = value;
}
); );
return statistics; return statistics;
} }
_computeSmoothedElevation(): number[] { _elevationComputation(statistics: GPXStatistics) {
const points = this.trkpt;
let smoothed = distanceWindowSmoothing(
points,
100,
(index) => points[index].ele ?? 0,
(accumulated, start, end) => accumulated / (end - start + 1)
);
if (points.length > 0) {
smoothed[0] = points[0].ele ?? 0;
smoothed[points.length - 1] = points[points.length - 1].ele ?? 0;
}
return smoothed;
}
_computeSlope(): number[] {
const points = this.trkpt;
return distanceWindowSmoothingWithDistanceAccumulator(
points,
50,
(accumulated, start, end) =>
(100 * ((points[end].ele ?? 0) - (points[start].ele ?? 0))) /
(accumulated > 0 ? accumulated : 1)
);
}
_computeSlopeSegments(statistics: GPXStatistics): [number[], number[]] {
let simplified = ramerDouglasPeucker( let simplified = ramerDouglasPeucker(
this.trkpt, this.trkpt,
20, 20,
getElevationDistanceFunction(statistics) getElevationDistanceFunction(statistics)
); );
let slope = []; for (let i = 0; i < simplified.length - 1; i++) {
let length = []; let start = simplified[i].point._data.index;
let end = simplified[i + 1].point._data.index;
let cumulEle = 0;
let currentStart = start;
let currentEnd = start;
let prevSmoothedEle = 0;
distanceWindowSmoothing(
start,
end + 1,
statistics,
0.1,
(s, e) => {
for (let i = currentStart; i < s; i++) {
cumulEle -= this.trkpt[i].ele ?? 0;
}
for (let i = currentEnd; i <= e; i++) {
cumulEle += this.trkpt[i].ele ?? 0;
}
currentStart = s;
currentEnd = e + 1;
return cumulEle / (e - s + 1);
},
(smoothedEle, j) => {
if (j === start) {
smoothedEle = this.trkpt[start].ele ?? 0;
prevSmoothedEle = smoothedEle;
} else if (j === end) {
smoothedEle = this.trkpt[end].ele ?? 0;
}
const ele = smoothedEle - prevSmoothedEle;
if (ele > 0) {
statistics.global.elevation.gain += ele;
} else if (ele < 0) {
statistics.global.elevation.loss -= ele;
}
prevSmoothedEle = smoothedEle;
if (j < end) {
statistics.local.data[j].elevation.gain = statistics.global.elevation.gain;
statistics.local.data[j].elevation.loss = statistics.global.elevation.loss;
}
}
);
}
if (statistics.global.length > 0) {
statistics.local.data[statistics.global.length - 1].elevation.gain =
statistics.global.elevation.gain;
statistics.local.data[statistics.global.length - 1].elevation.loss =
statistics.global.elevation.loss;
}
for (let i = 0; i < simplified.length - 1; i++) { for (let i = 0; i < simplified.length - 1; i++) {
let start = simplified[i].point._data.index; let start = simplified[i].point._data.index;
let end = simplified[i + 1].point._data.index; let end = simplified[i + 1].point._data.index;
let dist = let dist =
statistics.local.distance.total[end] - statistics.local.distance.total[start]; statistics.local.data[end].distance.total -
statistics.local.data[start].distance.total;
let ele = (simplified[i + 1].point.ele ?? 0) - (simplified[i].point.ele ?? 0); let ele = (simplified[i + 1].point.ele ?? 0) - (simplified[i].point.ele ?? 0);
for (let j = start; j < end + (i + 1 === simplified.length - 1 ? 1 : 0); j++) { for (let j = start; j < end + (i + 1 === simplified.length - 1 ? 1 : 0); j++) {
slope.push((0.1 * ele) / dist); statistics.local.data[j].slope.segment = (0.1 * ele) / dist;
length.push(dist); statistics.local.data[j].slope.length = dist;
} }
} }
return [slope, length]; distanceWindowSmoothing(
0,
this.trkpt.length,
statistics,
0.05,
(start, end) => {
const ele = this.trkpt[end].ele - this.trkpt[start].ele || 0;
const dist =
statistics.local.data[end].distance.total -
statistics.local.data[start].distance.total;
return dist > 0 ? (0.1 * ele) / dist : 0;
},
(value, index) => {
statistics.local.data[index].slope.at = value;
}
);
} }
getNumberOfTrackPoints(): number { getNumberOfTrackPoints(): number {
@@ -1290,8 +1316,8 @@ export class TrackSegment extends GPXTreeLeaf {
lastPoint: TrackPoint | undefined lastPoint: TrackPoint | undefined
) { ) {
let og = getOriginal(this); // Read as much as possible from the original object because it is faster let og = getOriginal(this); // Read as much as possible from the original object because it is faster
let slope = og._computeSlope(); let statistics = og._computeStatistics();
let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, slope); let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, statistics);
this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
} }
@@ -1300,6 +1326,7 @@ export class TrackSegment extends GPXTreeLeaf {
} }
} }
const emptyExtensions: Record<string, string> = {};
export class TrackPoint { export class TrackPoint {
[immerable] = true; [immerable] = true;
@@ -1310,7 +1337,7 @@ export class TrackPoint {
_data: { [key: string]: any } = {}; _data: { [key: string]: any } = {};
constructor(point: (TrackPointType & { _data?: any }) | TrackPoint) { constructor(point: (TrackPointType & { _data?: any }) | TrackPoint, index?: number) {
this.attributes = point.attributes; this.attributes = point.attributes;
this.ele = point.ele; this.ele = point.ele;
this.time = point.time; this.time = point.time;
@@ -1318,6 +1345,9 @@ export class TrackPoint {
if (point.hasOwnProperty('_data')) { if (point.hasOwnProperty('_data')) {
this._data = point._data; this._data = point._data;
} }
if (index !== undefined) {
this._data.index = index;
}
} }
getCoordinates(): Coordinates { getCoordinates(): Coordinates {
@@ -1391,7 +1421,7 @@ export class TrackPoint {
this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension'] &&
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']
? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']
: {}; : emptyExtensions;
} }
toTrackPointType(exclude: string[] = []): TrackPointType { toTrackPointType(exclude: string[] = []): TrackPointType {
@@ -1461,11 +1491,18 @@ export class TrackPoint {
clone(): TrackPoint { clone(): TrackPoint {
return new TrackPoint({ return new TrackPoint({
attributes: cloneJSON(this.attributes), attributes: {
lat: this.attributes.lat,
lon: this.attributes.lon,
},
ele: this.ele, ele: this.ele,
time: this.time ? new Date(this.time.getTime()) : undefined, time: this.time ? new Date(this.time.getTime()) : undefined,
extensions: cloneJSON(this.extensions), extensions: this.extensions ? cloneJSON(this.extensions) : undefined,
_data: cloneJSON(this._data), _data: {
index: this._data?.index,
anchor: this._data?.anchor,
zoom: this._data?.zoom,
},
}); });
} }
} }
@@ -1484,19 +1521,28 @@ export class Waypoint {
type?: string; type?: string;
_data: { [key: string]: any } = {}; _data: { [key: string]: any } = {};
constructor(waypoint: (WaypointType & { _data?: any }) | Waypoint) { constructor(waypoint: (WaypointType & { _data?: any }) | Waypoint, index?: number) {
this.attributes = waypoint.attributes; this.attributes = waypoint.attributes;
this.ele = waypoint.ele; this.ele = waypoint.ele;
this.time = waypoint.time; this.time = waypoint.time;
this.name = waypoint.name; this.name = waypoint.name === '' ? undefined : waypoint.name;
this.cmt = waypoint.cmt; this.cmt = waypoint.cmt === '' ? undefined : waypoint.cmt;
this.desc = waypoint.desc; this.desc = waypoint.desc === '' ? undefined : waypoint.desc;
this.link = waypoint.link; this.link =
this.sym = waypoint.sym; !waypoint.link ||
this.type = waypoint.type; !waypoint.link.attributes ||
!waypoint.link.attributes.href ||
waypoint.link.attributes.href === ''
? undefined
: waypoint.link;
this.sym = waypoint.sym === '' ? undefined : waypoint.sym;
this.type = waypoint.type === '' ? undefined : waypoint.type;
if (waypoint.hasOwnProperty('_data')) { if (waypoint.hasOwnProperty('_data')) {
this._data = waypoint._data; this._data = waypoint._data;
} }
if (index !== undefined) {
this._data.index = index;
}
} }
getCoordinates(): Coordinates { getCoordinates(): Coordinates {
@@ -1544,7 +1590,10 @@ export class Waypoint {
clone(): Waypoint { clone(): Waypoint {
return new Waypoint({ return new Waypoint({
attributes: cloneJSON(this.attributes), attributes: {
lat: this.attributes.lat,
lon: this.attributes.lon,
},
ele: this.ele, ele: this.ele,
time: this.time ? new Date(this.time.getTime()) : undefined, time: this.time ? new Date(this.time.getTime()) : undefined,
name: this.name, name: this.name,
@@ -1593,310 +1642,6 @@ export class Waypoint {
} }
} }
export class GPXStatistics {
global: {
distance: {
moving: number;
total: number;
};
time: {
start: Date | undefined;
end: Date | undefined;
moving: number;
total: number;
};
speed: {
moving: number;
total: number;
};
elevation: {
gain: number;
loss: number;
};
bounds: {
southWest: Coordinates;
northEast: Coordinates;
};
atemp: {
avg: number;
count: number;
};
hr: {
avg: number;
count: number;
};
cad: {
avg: number;
count: number;
};
power: {
avg: number;
count: number;
};
extensions: Record<string, Record<string, number>>;
};
local: {
points: TrackPoint[];
distance: {
moving: number[];
total: number[];
};
time: {
moving: number[];
total: number[];
};
speed: number[];
elevation: {
smoothed: number[];
gain: number[];
loss: number[];
};
slope: {
at: number[];
segment: number[];
length: number[];
};
};
constructor() {
this.global = {
distance: {
moving: 0,
total: 0,
},
time: {
start: undefined,
end: undefined,
moving: 0,
total: 0,
},
speed: {
moving: 0,
total: 0,
},
elevation: {
gain: 0,
loss: 0,
},
bounds: {
southWest: {
lat: 90,
lon: 180,
},
northEast: {
lat: -90,
lon: -180,
},
},
atemp: {
avg: 0,
count: 0,
},
hr: {
avg: 0,
count: 0,
},
cad: {
avg: 0,
count: 0,
},
power: {
avg: 0,
count: 0,
},
extensions: {},
};
this.local = {
points: [],
distance: {
moving: [],
total: [],
},
time: {
moving: [],
total: [],
},
speed: [],
elevation: {
smoothed: [],
gain: [],
loss: [],
},
slope: {
at: [],
segment: [],
length: [],
},
};
}
mergeWith(other: GPXStatistics): void {
this.local.points = this.local.points.concat(other.local.points);
this.local.distance.total = this.local.distance.total.concat(
other.local.distance.total.map((distance) => distance + this.global.distance.total)
);
this.local.distance.moving = this.local.distance.moving.concat(
other.local.distance.moving.map((distance) => distance + this.global.distance.moving)
);
this.local.time.total = this.local.time.total.concat(
other.local.time.total.map((time) => time + this.global.time.total)
);
this.local.time.moving = this.local.time.moving.concat(
other.local.time.moving.map((time) => time + this.global.time.moving)
);
this.local.elevation.gain = this.local.elevation.gain.concat(
other.local.elevation.gain.map((gain) => gain + this.global.elevation.gain)
);
this.local.elevation.loss = this.local.elevation.loss.concat(
other.local.elevation.loss.map((loss) => loss + this.global.elevation.loss)
);
this.local.speed = this.local.speed.concat(other.local.speed);
this.local.elevation.smoothed = this.local.elevation.smoothed.concat(
other.local.elevation.smoothed
);
this.local.slope.at = this.local.slope.at.concat(other.local.slope.at);
this.local.slope.segment = this.local.slope.segment.concat(other.local.slope.segment);
this.local.slope.length = this.local.slope.length.concat(other.local.slope.length);
this.global.distance.total += other.global.distance.total;
this.global.distance.moving += other.global.distance.moving;
this.global.time.start =
this.global.time.start !== undefined && other.global.time.start !== undefined
? new Date(
Math.min(this.global.time.start.getTime(), other.global.time.start.getTime())
)
: (this.global.time.start ?? other.global.time.start);
this.global.time.end =
this.global.time.end !== undefined && other.global.time.end !== undefined
? new Date(
Math.max(this.global.time.end.getTime(), other.global.time.end.getTime())
)
: (this.global.time.end ?? other.global.time.end);
this.global.time.total += other.global.time.total;
this.global.time.moving += other.global.time.moving;
this.global.speed.moving =
this.global.time.moving > 0
? this.global.distance.moving / (this.global.time.moving / 3600)
: 0;
this.global.speed.total =
this.global.time.total > 0
? this.global.distance.total / (this.global.time.total / 3600)
: 0;
this.global.elevation.gain += other.global.elevation.gain;
this.global.elevation.loss += other.global.elevation.loss;
this.global.bounds.southWest.lat = Math.min(
this.global.bounds.southWest.lat,
other.global.bounds.southWest.lat
);
this.global.bounds.southWest.lon = Math.min(
this.global.bounds.southWest.lon,
other.global.bounds.southWest.lon
);
this.global.bounds.northEast.lat = Math.max(
this.global.bounds.northEast.lat,
other.global.bounds.northEast.lat
);
this.global.bounds.northEast.lon = Math.max(
this.global.bounds.northEast.lon,
other.global.bounds.northEast.lon
);
this.global.atemp.avg =
(this.global.atemp.count * this.global.atemp.avg +
other.global.atemp.count * other.global.atemp.avg) /
Math.max(1, this.global.atemp.count + other.global.atemp.count);
this.global.atemp.count += other.global.atemp.count;
this.global.hr.avg =
(this.global.hr.count * this.global.hr.avg +
other.global.hr.count * other.global.hr.avg) /
Math.max(1, this.global.hr.count + other.global.hr.count);
this.global.hr.count += other.global.hr.count;
this.global.cad.avg =
(this.global.cad.count * this.global.cad.avg +
other.global.cad.count * other.global.cad.avg) /
Math.max(1, this.global.cad.count + other.global.cad.count);
this.global.cad.count += other.global.cad.count;
this.global.power.avg =
(this.global.power.count * this.global.power.avg +
other.global.power.count * other.global.power.avg) /
Math.max(1, this.global.power.count + other.global.power.count);
this.global.power.count += other.global.power.count;
Object.keys(other.global.extensions).forEach((extension) => {
if (this.global.extensions[extension] === undefined) {
this.global.extensions[extension] = {};
}
Object.keys(other.global.extensions[extension]).forEach((value) => {
if (this.global.extensions[extension][value] === undefined) {
this.global.extensions[extension][value] = 0;
}
this.global.extensions[extension][value] +=
other.global.extensions[extension][value];
});
});
}
slice(start: number, end: number): GPXStatistics {
if (start < 0) {
start = 0;
} else if (start >= this.local.points.length) {
return new GPXStatistics();
}
if (end < start) {
return new GPXStatistics();
} else if (end >= this.local.points.length) {
end = this.local.points.length - 1;
}
let statistics = new GPXStatistics();
statistics.local.points = this.local.points.slice(start, end + 1);
statistics.global.distance.total =
this.local.distance.total[end] - this.local.distance.total[start];
statistics.global.distance.moving =
this.local.distance.moving[end] - this.local.distance.moving[start];
statistics.global.time.start = this.local.points[start].time;
statistics.global.time.end = this.local.points[end].time;
statistics.global.time.total = this.local.time.total[end] - this.local.time.total[start];
statistics.global.time.moving = this.local.time.moving[end] - this.local.time.moving[start];
statistics.global.speed.moving =
statistics.global.time.moving > 0
? statistics.global.distance.moving / (statistics.global.time.moving / 3600)
: 0;
statistics.global.speed.total =
statistics.global.time.total > 0
? statistics.global.distance.total / (statistics.global.time.total / 3600)
: 0;
statistics.global.elevation.gain =
this.local.elevation.gain[end] - this.local.elevation.gain[start];
statistics.global.elevation.loss =
this.local.elevation.loss[end] - this.local.elevation.loss[start];
statistics.global.bounds.southWest.lat = this.global.bounds.southWest.lat;
statistics.global.bounds.southWest.lon = this.global.bounds.southWest.lon;
statistics.global.bounds.northEast.lat = this.global.bounds.northEast.lat;
statistics.global.bounds.northEast.lon = this.global.bounds.northEast.lon;
statistics.global.atemp = this.global.atemp;
statistics.global.hr = this.global.hr;
statistics.global.cad = this.global.cad;
statistics.global.power = this.global.power;
return statistics;
}
}
const earthRadius = 6371008.8; const earthRadius = 6371008.8;
export function distance( export function distance(
coord1: TrackPoint | Coordinates, coord1: TrackPoint | Coordinates,
@@ -1911,11 +1656,15 @@ export function distance(
const rad = Math.PI / 180; const rad = Math.PI / 180;
const lat1 = coord1.lat * rad; const lat1 = coord1.lat * rad;
const lat2 = coord2.lat * rad; const lat2 = coord2.lat * rad;
const dLat = lat2 - lat1;
const dLon = (coord2.lon - coord1.lon) * rad;
// Haversine formula - better numerical stability for small distances
const a = const a =
Math.sin(lat1) * Math.sin(lat2) + Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1) * Math.cos(lat2) * Math.cos((coord2.lon - coord1.lon) * rad); Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const maxMeters = earthRadius * Math.acos(Math.min(a, 1)); const c = 2 * Math.asin(Math.sqrt(Math.min(a, 1)));
return maxMeters; return earthRadius * c;
} }
export function getElevationDistanceFunction(statistics: GPXStatistics) { export function getElevationDistanceFunction(statistics: GPXStatistics) {
@@ -1926,9 +1675,9 @@ export function getElevationDistanceFunction(statistics: GPXStatistics) {
if (point1.ele === undefined || point2.ele === undefined || point3.ele === undefined) { if (point1.ele === undefined || point2.ele === undefined || point3.ele === undefined) {
return 0; return 0;
} }
let x1 = statistics.local.distance.total[point1._data.index] * 1000; let x1 = statistics.local.data[point1._data.index].distance.total * 1000;
let x2 = statistics.local.distance.total[point2._data.index] * 1000; let x2 = statistics.local.data[point2._data.index].distance.total * 1000;
let x3 = statistics.local.distance.total[point3._data.index] * 1000; let x3 = statistics.local.data[point3._data.index].distance.total * 1000;
let y1 = point1.ele; let y1 = point1.ele;
let y2 = point2.ele; let y2 = point2.ele;
let y3 = point3.ele; let y3 = point3.ele;
@@ -1942,57 +1691,61 @@ export function getElevationDistanceFunction(statistics: GPXStatistics) {
}; };
} }
function distanceWindowSmoothing( function windowSmoothing(
points: TrackPoint[], left: number,
distanceWindow: number, right: number,
accumulate: (index: number) => number, distance: (index1: number, index2: number) => number,
compute: (accumulated: number, start: number, end: number) => number, window: number,
remove?: (index: number) => number compute: (start: number, end: number) => number,
): number[] { callback: (value: number, index: number) => void
let result = []; ): void {
let start = left;
let start = 0, for (var i = left; i < right; i++) {
end = 0, while (start + 1 < i && distance(start, i) > window) {
accumulated = 0;
for (var i = 0; i < points.length; i++) {
while (
start + 1 < i &&
distance(points[start].getCoordinates(), points[i].getCoordinates()) > distanceWindow
) {
if (remove) {
accumulated -= remove(start);
} else {
accumulated -= accumulate(start);
}
start++; start++;
} }
while ( let end = Math.min(i + 2, right);
end < points.length && while (end < right && distance(i, end) <= window) {
distance(points[i].getCoordinates(), points[end].getCoordinates()) <= distanceWindow
) {
accumulated += accumulate(end);
end++; end++;
} }
result[i] = compute(accumulated, start, end - 1); callback(compute(start, end - 1), i);
}
} }
return result; function distanceWindowSmoothing(
} left: number,
right: number,
function distanceWindowSmoothingWithDistanceAccumulator( statistics: GPXStatistics,
points: TrackPoint[], window: number,
distanceWindow: number, compute: (start: number, end: number) => number,
compute: (accumulated: number, start: number, end: number) => number callback: (value: number, index: number) => void
): number[] { ): void {
return distanceWindowSmoothing( windowSmoothing(
points, left,
distanceWindow, right,
(index) => (index1, index2) =>
index > 0 statistics.local.data[index2].distance.total -
? distance(points[index - 1].getCoordinates(), points[index].getCoordinates()) statistics.local.data[index1].distance.total,
: 0, window,
compute, compute,
(index) => distance(points[index].getCoordinates(), points[index + 1].getCoordinates()) callback
);
}
function timeWindowSmoothing(
points: TrackPoint[],
window: number,
compute: (start: number, end: number) => number,
callback: (value: number, index: number) => void
): void {
windowSmoothing(
0,
points.length,
(index1, index2) =>
points[index2].time?.getTime() - points[index1].time?.getTime() || 2 * window,
window,
compute,
callback
); );
} }
@@ -2044,14 +1797,14 @@ function withArtificialTimestamps(
totalTime: number, totalTime: number,
lastPoint: TrackPoint | undefined, lastPoint: TrackPoint | undefined,
startTime: Date, startTime: Date,
slope: number[] statistics: GPXStatistics
): TrackPoint[] { ): TrackPoint[] {
let weight = []; let weight = [];
let totalWeight = 0; let totalWeight = 0;
for (let i = 0; i < points.length - 1; i++) { for (let i = 0; i < points.length - 1; i++) {
let dist = distance(points[i].getCoordinates(), points[i + 1].getCoordinates()); let dist = distance(points[i].getCoordinates(), points[i + 1].getCoordinates());
let w = dist * (0.5 + 1 / (1 + Math.exp(-0.2 * slope[i]))); let w = dist * (0.5 + 1 / (1 + Math.exp(-0.2 * statistics.local.data[i].slope.at)));
weight.push(w); weight.push(w);
totalWeight += w; totalWeight += w;
} }
+1
View File
@@ -1,4 +1,5 @@
export * from './gpx'; export * from './gpx';
export * from './statistics';
export { Coordinates, LineStyleExtension, WaypointType } from './types'; export { Coordinates, LineStyleExtension, WaypointType } from './types';
export { parseGPX, buildGPX } from './io'; export { parseGPX, buildGPX } from './io';
export * from './simplify'; export * from './simplify';
+59 -98
View File
@@ -3,8 +3,6 @@ import { Coordinates } from './types';
export type SimplifiedTrackPoint = { point: TrackPoint; distance?: number }; export type SimplifiedTrackPoint = { point: TrackPoint; distance?: number };
const earthRadius = 6371008.8;
export function ramerDouglasPeucker( export function ramerDouglasPeucker(
points: TrackPoint[], points: TrackPoint[],
epsilon: number = 50, epsilon: number = 50,
@@ -61,76 +59,56 @@ function ramerDouglasPeuckerRecursive(
} }
export function crossarcDistance( export function crossarcDistance(
point1: TrackPoint, point1: TrackPoint | Coordinates,
point2: TrackPoint, point2: TrackPoint | Coordinates,
point3: TrackPoint | Coordinates point3: TrackPoint | Coordinates
): number { ): number {
return crossarc( return crossarc(
point1.getCoordinates(), point1 instanceof TrackPoint ? point1.getCoordinates() : point1,
point2.getCoordinates(), point2 instanceof TrackPoint ? point2.getCoordinates() : point2,
point3 instanceof TrackPoint ? point3.getCoordinates() : point3 point3 instanceof TrackPoint ? point3.getCoordinates() : point3
); );
} }
const metersPerLatitudeDegree = 111320;
function getMetersPerLongitudeDegree(latitude: number): number {
return Math.cos((latitude * Math.PI) / 180) * metersPerLatitudeDegree;
}
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number { function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
// Calculates the shortest distance in meters // Calculates the perpendicular distance in meters
// between an arc (defined by p1 and p2) and a third point, p3. // between a line segment (defined by p1 and p2) and a third point, p3.
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees. // Uses simple planar geometry (ignores earth curvature).
const rad = Math.PI / 180; // Convert to meters using approximate scaling
const lat1 = coord1.lat * rad; const metersPerLongitudeDegree = getMetersPerLongitudeDegree(coord1.lat);
const lat2 = coord2.lat * rad;
const lat3 = coord3.lat * rad;
const lon1 = coord1.lon * rad; const x1 = coord1.lon * metersPerLongitudeDegree;
const lon2 = coord2.lon * rad; const y1 = coord1.lat * metersPerLatitudeDegree;
const lon3 = coord3.lon * rad; const x2 = coord2.lon * metersPerLongitudeDegree;
const y2 = coord2.lat * metersPerLatitudeDegree;
const x3 = coord3.lon * metersPerLongitudeDegree;
const y3 = coord3.lat * metersPerLatitudeDegree;
// Prerequisites for the formulas const dx = x2 - x1;
const bear12 = bearing(lat1, lon1, lat2, lon2); const dy = y2 - y1;
const bear13 = bearing(lat1, lon1, lat3, lon3); const segmentLengthSquared = dx * dx + dy * dy;
let dis13 = distance(lat1, lon1, lat3, lon3);
let diff = Math.abs(bear13 - bear12); if (segmentLengthSquared === 0) {
if (diff > Math.PI) { // p1 and p2 are the same point
diff = 2 * Math.PI - diff; return Math.sqrt((x3 - x1) * (x3 - x1) + (y3 - y1) * (y3 - y1));
} }
// Is relative bearing obtuse? // Project p3 onto the line defined by p1-p2
if (diff > Math.PI / 2) { const t = Math.max(0, Math.min(1, ((x3 - x1) * dx + (y3 - y1) * dy) / segmentLengthSquared));
return dis13;
}
// Find the cross-track distance. // Find the closest point on the segment
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius; const projX = x1 + t * dx;
const projY = y1 + t * dy;
// Is p4 beyond the arc? // Return distance from p3 to the projected point
let dis12 = distance(lat1, lon1, lat2, lon2); return Math.sqrt((x3 - projX) * (x3 - projX) + (y3 - projY) * (y3 - projY));
let dis14 =
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
if (dis14 > dis12) {
return distance(lat2, lon2, lat3, lon3);
} else {
return Math.abs(dxt);
}
}
function distance(latA: number, lonA: number, latB: number, lonB: number): number {
// Finds the distance between two lat / lon points.
return (
Math.acos(
Math.sin(latA) * Math.sin(latB) +
Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
) * earthRadius
);
}
function bearing(latA: number, lonA: number, latB: number, lonB: number): number {
// Finds the bearing from one lat / lon point to another.
return Math.atan2(
Math.sin(lonB - lonA) * Math.cos(latB),
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
);
} }
export function projectedPoint( export function projectedPoint(
@@ -146,56 +124,39 @@ export function projectedPoint(
} }
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates { function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
// Calculates the point on the line defined by p1 and p2 // Calculates the point on the line segment defined by p1 and p2
// that is closest to the third point, p3. // that is closest to the third point, p3.
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees. // Uses simple planar geometry (ignores earth curvature).
const rad = Math.PI / 180; // Convert to meters using approximate scaling
const lat1 = coord1.lat * rad; const metersPerLongitudeDegree = getMetersPerLongitudeDegree(coord1.lat);
const lat2 = coord2.lat * rad;
const lat3 = coord3.lat * rad;
const lon1 = coord1.lon * rad; const x1 = coord1.lon * metersPerLongitudeDegree;
const lon2 = coord2.lon * rad; const y1 = coord1.lat * metersPerLatitudeDegree;
const lon3 = coord3.lon * rad; const x2 = coord2.lon * metersPerLongitudeDegree;
const y2 = coord2.lat * metersPerLatitudeDegree;
const x3 = coord3.lon * metersPerLongitudeDegree;
const y3 = coord3.lat * metersPerLatitudeDegree;
// Prerequisites for the formulas const dx = x2 - x1;
const bear12 = bearing(lat1, lon1, lat2, lon2); const dy = y2 - y1;
const bear13 = bearing(lat1, lon1, lat3, lon3); const segmentLengthSquared = dx * dx + dy * dy;
let dis13 = distance(lat1, lon1, lat3, lon3);
let diff = Math.abs(bear13 - bear12); if (segmentLengthSquared === 0) {
if (diff > Math.PI) { // p1 and p2 are the same point
diff = 2 * Math.PI - diff;
}
// Is relative bearing obtuse?
if (diff > Math.PI / 2) {
return coord1; return coord1;
} }
// Find the cross-track distance. // Project p3 onto the line defined by p1-p2
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius; const t = Math.max(0, Math.min(1, ((x3 - x1) * dx + (y3 - y1) * dy) / segmentLengthSquared));
// Is p4 beyond the arc? // Find the closest point on the segment
let dis12 = distance(lat1, lon1, lat2, lon2); const projX = x1 + t * dx;
let dis14 = const projY = y1 + t * dy;
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
if (dis14 > dis12) {
return coord2;
} else {
// Determine the closest point (p4) on the great circle
const f = dis14 / earthRadius;
const lat4 = Math.asin(
Math.sin(lat1) * Math.cos(f) + Math.cos(lat1) * Math.sin(f) * Math.cos(bear12)
);
const lon4 =
lon1 +
Math.atan2(
Math.sin(bear12) * Math.sin(f) * Math.cos(lat1),
Math.cos(f) - Math.sin(lat1) * Math.sin(lat4)
);
return { lat: lat4 / rad, lon: lon4 / rad }; // Convert back to degrees
} return {
lat: projY / metersPerLatitudeDegree,
lon: projX / metersPerLongitudeDegree,
};
} }
+391
View File
@@ -0,0 +1,391 @@
import { TrackPoint } from './gpx';
import { Coordinates } from './types';
export class GPXGlobalStatistics {
length: number;
distance: {
moving: number;
total: number;
};
time: {
start: Date | undefined;
end: Date | undefined;
moving: number;
total: number;
};
speed: {
moving: number;
total: number;
};
elevation: {
gain: number;
loss: number;
};
bounds: {
southWest: Coordinates;
northEast: Coordinates;
};
atemp: {
avg: number;
count: number;
};
hr: {
avg: number;
count: number;
};
cad: {
avg: number;
count: number;
};
power: {
avg: number;
count: number;
};
extensions: Record<string, Record<string, number>>;
constructor() {
this.length = 0;
this.distance = {
moving: 0,
total: 0,
};
this.time = {
start: undefined,
end: undefined,
moving: 0,
total: 0,
};
this.speed = {
moving: 0,
total: 0,
};
this.elevation = {
gain: 0,
loss: 0,
};
this.bounds = {
southWest: {
lat: 90,
lon: 180,
},
northEast: {
lat: -90,
lon: -180,
},
};
this.atemp = {
avg: 0,
count: 0,
};
this.hr = {
avg: 0,
count: 0,
};
this.cad = {
avg: 0,
count: 0,
};
this.power = {
avg: 0,
count: 0,
};
this.extensions = {};
}
mergeWith(other: GPXGlobalStatistics): void {
this.length += other.length;
this.distance.total += other.distance.total;
this.distance.moving += other.distance.moving;
this.time.start =
this.time.start !== undefined && other.time.start !== undefined
? new Date(Math.min(this.time.start.getTime(), other.time.start.getTime()))
: (this.time.start ?? other.time.start);
this.time.end =
this.time.end !== undefined && other.time.end !== undefined
? new Date(Math.max(this.time.end.getTime(), other.time.end.getTime()))
: (this.time.end ?? other.time.end);
this.time.total += other.time.total;
this.time.moving += other.time.moving;
this.speed.moving =
this.time.moving > 0 ? this.distance.moving / (this.time.moving / 3600) : 0;
this.speed.total = this.time.total > 0 ? this.distance.total / (this.time.total / 3600) : 0;
this.elevation.gain += other.elevation.gain;
this.elevation.loss += other.elevation.loss;
this.bounds.southWest.lat = Math.min(this.bounds.southWest.lat, other.bounds.southWest.lat);
this.bounds.southWest.lon = Math.min(this.bounds.southWest.lon, other.bounds.southWest.lon);
this.bounds.northEast.lat = Math.max(this.bounds.northEast.lat, other.bounds.northEast.lat);
this.bounds.northEast.lon = Math.max(this.bounds.northEast.lon, other.bounds.northEast.lon);
this.atemp.avg =
(this.atemp.count * this.atemp.avg + other.atemp.count * other.atemp.avg) /
Math.max(1, this.atemp.count + other.atemp.count);
this.atemp.count += other.atemp.count;
this.hr.avg =
(this.hr.count * this.hr.avg + other.hr.count * other.hr.avg) /
Math.max(1, this.hr.count + other.hr.count);
this.hr.count += other.hr.count;
this.cad.avg =
(this.cad.count * this.cad.avg + other.cad.count * other.cad.avg) /
Math.max(1, this.cad.count + other.cad.count);
this.cad.count += other.cad.count;
this.power.avg =
(this.power.count * this.power.avg + other.power.count * other.power.avg) /
Math.max(1, this.power.count + other.power.count);
this.power.count += other.power.count;
Object.keys(other.extensions).forEach((extension) => {
if (this.extensions[extension] === undefined) {
this.extensions[extension] = {};
}
Object.keys(other.extensions[extension]).forEach((value) => {
if (this.extensions[extension][value] === undefined) {
this.extensions[extension][value] = 0;
}
this.extensions[extension][value] += other.extensions[extension][value];
});
});
}
}
export class TrackPointLocalStatistics {
distance: {
moving: number;
total: number;
};
time: {
moving: number;
total: number;
};
speed: number;
elevation: {
gain: number;
loss: number;
};
slope: {
at: number;
segment: number;
length: number;
};
constructor() {
this.distance = {
moving: 0,
total: 0,
};
this.time = {
moving: 0,
total: 0,
};
this.speed = 0;
this.elevation = {
gain: 0,
loss: 0,
};
this.slope = {
at: 0,
segment: 0,
length: 0,
};
}
}
export class GPXLocalStatistics {
points: TrackPoint[];
data: TrackPointLocalStatistics[];
constructor() {
this.points = [];
this.data = [];
}
}
export type TrackPointWithLocalStatistics = {
trkpt: TrackPoint;
} & TrackPointLocalStatistics;
export class GPXStatistics {
global: GPXGlobalStatistics;
local: GPXLocalStatistics;
constructor() {
this.global = new GPXGlobalStatistics();
this.local = new GPXLocalStatistics();
}
sliced(start: number, end: number): GPXGlobalStatistics {
if (start < 0) {
start = 0;
} else if (start >= this.global.length) {
return new GPXGlobalStatistics();
}
if (end < start) {
return new GPXGlobalStatistics();
} else if (end >= this.global.length) {
end = this.global.length - 1;
}
if (start === 0 && end === this.global.length - 1) {
return this.global;
}
let statistics = new GPXGlobalStatistics();
statistics.length = end - start + 1;
statistics.distance.total =
this.local.data[end].distance.total - this.local.data[start].distance.total;
statistics.distance.moving =
this.local.data[end].distance.moving - this.local.data[start].distance.moving;
statistics.time.start = this.local.points[start].time;
statistics.time.end = this.local.points[end].time;
statistics.time.total = this.local.data[end].time.total - this.local.data[start].time.total;
statistics.time.moving =
this.local.data[end].time.moving - this.local.data[start].time.moving;
statistics.speed.moving =
statistics.time.moving > 0
? statistics.distance.moving / (statistics.time.moving / 3600)
: 0;
statistics.speed.total =
statistics.time.total > 0
? statistics.distance.total / (statistics.time.total / 3600)
: 0;
statistics.elevation.gain =
this.local.data[end].elevation.gain - this.local.data[start].elevation.gain;
statistics.elevation.loss =
this.local.data[end].elevation.loss - this.local.data[start].elevation.loss;
statistics.bounds.southWest.lat = this.global.bounds.southWest.lat;
statistics.bounds.southWest.lon = this.global.bounds.southWest.lon;
statistics.bounds.northEast.lat = this.global.bounds.northEast.lat;
statistics.bounds.northEast.lon = this.global.bounds.northEast.lon;
statistics.atemp = this.global.atemp;
statistics.hr = this.global.hr;
statistics.cad = this.global.cad;
statistics.power = this.global.power;
return statistics;
}
}
export class GPXStatisticsGroup {
private _statistics: GPXStatistics[];
private _cumulative: GPXGlobalStatistics[];
private _slice: [number, number] | null = null;
global: GPXGlobalStatistics;
constructor() {
this._statistics = [];
this._cumulative = [new GPXGlobalStatistics()];
this.global = new GPXGlobalStatistics();
}
add(statistics: GPXStatistics | GPXStatisticsGroup): void {
if (statistics instanceof GPXStatisticsGroup) {
statistics._statistics.forEach((stats) => this._add(stats));
} else {
this._add(statistics);
}
}
_add(statistics: GPXStatistics): void {
this._statistics.push(statistics);
const cumulative = new GPXGlobalStatistics();
cumulative.mergeWith(this._cumulative[this._cumulative.length - 1]);
cumulative.mergeWith(statistics.global);
this._cumulative.push(cumulative);
this.global.mergeWith(statistics.global);
}
sliced(start: number, end: number): GPXGlobalStatistics {
let sliced = new GPXGlobalStatistics();
for (let i = 0; i < this._statistics.length; i++) {
const statistics = this._statistics[i];
const cumulative = this._cumulative[i];
if (start < cumulative.length + statistics.global.length && end >= cumulative.length) {
const localStart = Math.max(0, start - cumulative.length);
const localEnd = Math.min(statistics.global.length - 1, end - cumulative.length);
sliced.mergeWith(statistics.sliced(localStart, localEnd));
}
}
return sliced;
}
getTrackPoint(index: number): TrackPointWithLocalStatistics | undefined {
if (this._slice !== null) {
index += this._slice[0];
}
for (let i = 0; i < this._statistics.length; i++) {
const statistics = this._statistics[i];
const cumulative = this._cumulative[i];
if (index < cumulative.length + statistics.global.length) {
return this._getTrackPoint(cumulative, statistics, index - cumulative.length);
}
}
return undefined;
}
_getTrackPoint(
cumulative: GPXGlobalStatistics,
statistics: GPXStatistics,
index: number
): TrackPointWithLocalStatistics {
const point = statistics.local.points[index];
return {
trkpt: point,
distance: {
moving: statistics.local.data[index].distance.moving + cumulative.distance.moving,
total: statistics.local.data[index].distance.total + cumulative.distance.total,
},
time: {
moving: statistics.local.data[index].time.moving + cumulative.time.moving,
total: statistics.local.data[index].time.total + cumulative.time.total,
},
speed: statistics.local.data[index].speed,
elevation: {
gain: statistics.local.data[index].elevation.gain + cumulative.elevation.gain,
loss: statistics.local.data[index].elevation.loss + cumulative.elevation.loss,
},
slope: {
at: statistics.local.data[index].slope.at,
segment: statistics.local.data[index].slope.segment,
length: statistics.local.data[index].slope.length,
},
};
}
forEachTrackPoint(
callback: (
point: TrackPoint,
distance: number,
speed: number,
slope: { at: number; segment: number; length: number },
index: number
) => void
): void {
for (let i = 0; i < this._statistics.length; i++) {
const statistics = this._statistics[i];
const cumulative = this._cumulative[i];
statistics.local.points.forEach((point, index) =>
callback(
point,
cumulative.distance.total + statistics.local.data[index].distance.total,
statistics.local.data[index].speed,
statistics.local.data[index].slope,
cumulative.length + index
)
);
}
}
}
-6
View File
@@ -1,6 +0,0 @@
{
"name": "gpx.studio",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+1 -1
View File
@@ -1 +1 @@
PUBLIC_MAPBOX_TOKEN=YOUR_MAPBOX_TOKEN PUBLIC_MAPTILER_KEY=YOUR_MAPTILER_KEY
+249 -1042
View File
File diff suppressed because it is too large Load Diff
+7 -10
View File
@@ -10,8 +10,8 @@
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .",
"format": "prettier --write ." "format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore"
}, },
"devDependencies": { "devDependencies": {
"@lucide/svelte": "^0.544.0", "@lucide/svelte": "^0.544.0",
@@ -23,15 +23,14 @@
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/events": "^3.0.3", "@types/events": "^3.0.3",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/mapbox__sphericalmercator": "^1.2.3",
"@types/mapbox__tilebelt": "^1.0.4", "@types/mapbox__tilebelt": "^1.0.4",
"@types/mapbox-gl": "^3.4.1",
"@types/node": "^22.15.30", "@types/node": "^22.15.30",
"@types/png.js": "^0.2.3",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.16.0",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^8.33.1", "@typescript-eslint/eslint-plugin": "^8.33.1",
"@typescript-eslint/parser": "^8.33.1", "@typescript-eslint/parser": "^8.33.1",
"bits-ui": "^2.12.0", "bits-ui": "^2.14.4",
"eslint": "^9.28.0", "eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.9.1", "eslint-plugin-svelte": "^3.9.1",
@@ -62,11 +61,10 @@
"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.4.9", "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",
"dexie": "^4.0.11", "dexie": "^4.0.11",
@@ -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.12.0",
"mapillary-js": "^4.1.2", "mapillary-js": "^4.1.2",
"png.js": "^0.2.1", "maplibre-gl": "^5.16.0",
"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"
+4 -2
View File
@@ -1,5 +1,5 @@
@import "tailwindcss"; @import 'tailwindcss';
@import "tw-animate-css"; @import 'tw-animate-css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -34,6 +34,7 @@
--support: rgb(220 15 130); --support: rgb(220 15 130);
--link: rgb(0 110 180); --link: rgb(0 110 180);
--selection: hsl(240 4.8% 93%);
--radius: 0.5rem; --radius: 0.5rem;
} }
@@ -69,6 +70,7 @@
--support: rgb(255 110 190); --support: rgb(255 110 190);
--link: rgb(80 190 255); --link: rgb(80 190 255);
--selection: hsl(240 3.7% 22%);
} }
@theme inline { @theme inline {
+8
View File
@@ -24,6 +24,14 @@ export async function handle({ event, resolve }) {
let headTag = `<head> let headTag = `<head>
<title>gpx.studio — ${title}</title> <title>gpx.studio — ${title}</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "gpx.studio",
"url": "https://gpx.studio"
}
</script>
<meta name="description" content="${description}" /> <meta name="description" content="${description}" />
<meta property="og:title" content="gpx.studio — ${title}" /> <meta property="og:title" content="gpx.studio — ${title}" />
<meta property="og:description" content="${description}" /> <meta property="og:description" content="${description}" />
@@ -17,7 +17,6 @@
} }
}, },
"sprite": "https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite", "sprite": "https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite",
"glyphs": "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key={key}",
"layers": [ "layers": [
{ {
"id": "background", "id": "background",

Before

Width:  |  Height:  |  Size: 3.6 MiB

After

Width:  |  Height:  |  Size: 3.6 MiB

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

+114 -20
View File
@@ -22,15 +22,18 @@ import {
Binoculars, Binoculars,
Toilet, Toilet,
} from 'lucide-static'; } from 'lucide-static';
import { 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', maptilerTopo: `https://api.maptiler.com/maps/topo-v4/style.json?key=${maptilerKeyPlaceHolder}`,
mapboxSatellite: 'mapbox://styles/mapbox/satellite-streets-v12', 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: {
@@ -145,18 +148,19 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
swisstopoVector: 'https://vectortiles.geo.admin.ch/styles/ch.swisstopo.basemap.vt/style.json', swisstopoVector: 'https://vectortiles.geo.admin.ch/styles/ch.swisstopo.basemap.vt/style.json',
swisstopoSatellite: swisstopoSatellite:
'https://vectortiles.geo.admin.ch/styles/ch.swisstopo.imagerybasemap.vt/style.json', 'https://vectortiles.geo.admin.ch/styles/ch.swisstopo.imagerybasemap.vt/style.json',
linz: 'https://basemaps.linz.govt.nz/v1/tiles/topographic/EPSG:3857/style/topographic.json?api=d01fbtg0ar23gctac5m0jgyy2ds', linz: 'https://basemaps.linz.govt.nz/v1/styles/topographic-v2.json?api=d01fbtg0ar23gctac5m0jgyy2ds',
linzTopo: { linzTopo: {
version: 8, version: 8,
sources: { sources: {
linzTopo: { linzTopo: {
type: 'raster', type: 'raster',
tiles: [ tiles: [
'https://tiles-cdn.koordinates.com/services;key=39a8b989633a4bef98bc0e065380454a/tiles/v4/layer=50767/EPSG:3857/{z}/{x}/{y}.png', 'https://basemaps.linz.govt.nz/v1/tiles/topo-raster/WebMercatorQuad/{z}/{x}/{y}.webp?api=d01fbtg0ar23gctac5m0jgyy2ds',
], ],
tileSize: 256, tileSize: 256,
maxzoom: 18, maxzoom: 16,
attribution: '&copy; <a href="https://www.linz.govt.nz/" target="_blank">LINZ</a>', attribution:
'© <a href="//www.linz.govt.nz/linz-copyright">LINZ CC BY 4.0</a> © <a href="//www.linz.govt.nz/data/linz-data/linz-basemaps/data-attribution">Imagery Basemap contributors</a>',
}, },
}, },
layers: [ layers: [
@@ -367,6 +371,42 @@ export const overlays: { [key: string]: string | StyleSpecification } = {
], ],
}, },
bikerouterGravel: bikerouterGravel as StyleSpecification, bikerouterGravel: bikerouterGravel as StyleSpecification,
openRailwayMap: {
version: 8,
sources: {
openRailwayMap: {
type: 'raster',
tiles: ['https://tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 19,
attribution:
'Data <a href="https://www.openstreetmap.org/copyright">&copy; OpenStreetMap contributors</a>, Style: <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA 2.0</a> <a href="http://www.openrailwaymap.org/">OpenRailwayMap</a>',
},
},
layers: [
{
id: 'openRailwayMap',
type: 'raster',
source: 'openRailwayMap',
},
],
},
mapterhornHillshade: {
version: 8,
sources: {
mapterhornHillshade: {
type: 'raster-dem',
url: 'https://tiles.mapterhorn.com/tilejson.json',
},
},
layers: [
{
id: 'mapterhornHillshade',
type: 'hillshade',
source: 'mapterhornHillshade',
},
],
},
swisstopoSlope: { swisstopoSlope: {
version: 8, version: 8,
sources: { sources: {
@@ -736,8 +776,9 @@ export type LayerTreeType = { [key: string]: LayerTreeType | boolean };
export const basemapTree: LayerTreeType = { export const basemapTree: LayerTreeType = {
basemaps: { basemaps: {
world: { world: {
mapboxOutdoors: true, maptilerTopo: true,
mapboxSatellite: true, maptilerOutdoors: true,
maptilerSatellite: true,
openStreetMap: true, openStreetMap: true,
openTopoMap: true, openTopoMap: true,
openHikingMap: true, openHikingMap: true,
@@ -798,8 +839,10 @@ export const overlayTree: LayerTreeType = {
waymarkedTrailsHorseRiding: true, waymarkedTrailsHorseRiding: true,
waymarkedTrailsWinter: true, waymarkedTrailsWinter: true,
}, },
cyclOSMlite: true,
bikerouterGravel: true, bikerouterGravel: true,
cyclOSMlite: true,
mapterhornHillshade: true,
openRailwayMap: true,
}, },
countries: { countries: {
france: { france: {
@@ -835,6 +878,7 @@ export const overpassTree: LayerTreeType = {
shower: true, shower: true,
shelter: true, shelter: true,
barrier: true, barrier: true,
cemetery: true,
}, },
tourism: { tourism: {
attraction: true, attraction: true,
@@ -867,7 +911,7 @@ export const overpassTree: LayerTreeType = {
}; };
// Default basemap used // Default basemap used
export const defaultBasemap = 'mapboxOutdoors'; export const defaultBasemap = 'maptilerTopo';
// Default overlays used (none) // Default overlays used (none)
export const defaultOverlays: LayerTreeType = { export const defaultOverlays: LayerTreeType = {
@@ -881,8 +925,10 @@ export const defaultOverlays: LayerTreeType = {
waymarkedTrailsHorseRiding: false, waymarkedTrailsHorseRiding: false,
waymarkedTrailsWinter: false, waymarkedTrailsWinter: false,
}, },
cyclOSMlite: false,
bikerouterGravel: false, bikerouterGravel: false,
cyclOSMlite: false,
mapterhornHillshade: false,
openRailwayMap: false,
}, },
countries: { countries: {
france: { france: {
@@ -918,6 +964,7 @@ export const defaultOverpassQueries: LayerTreeType = {
shower: false, shower: false,
shelter: false, shelter: false,
barrier: false, barrier: false,
cemetery: false,
}, },
tourism: { tourism: {
attraction: false, attraction: false,
@@ -953,8 +1000,9 @@ export const defaultOverpassQueries: LayerTreeType = {
export const defaultBasemapTree: LayerTreeType = { export const defaultBasemapTree: LayerTreeType = {
basemaps: { basemaps: {
world: { world: {
mapboxOutdoors: true, maptilerTopo: true,
mapboxSatellite: true, maptilerOutdoors: true,
maptilerSatellite: true,
openStreetMap: true, openStreetMap: true,
openTopoMap: true, openTopoMap: true,
openHikingMap: true, openHikingMap: true,
@@ -1015,8 +1063,10 @@ export const defaultOverlayTree: LayerTreeType = {
waymarkedTrailsHorseRiding: false, waymarkedTrailsHorseRiding: false,
waymarkedTrailsWinter: false, waymarkedTrailsWinter: false,
}, },
cyclOSMlite: false,
bikerouterGravel: false, bikerouterGravel: false,
cyclOSMlite: false,
mapterhornHillshade: false,
openRailwayMap: false,
}, },
countries: { countries: {
france: { france: {
@@ -1052,6 +1102,7 @@ export const defaultOverpassTree: LayerTreeType = {
shower: false, shower: false,
shelter: false, shelter: false,
barrier: false, barrier: false,
cemetery: false,
}, },
tourism: { tourism: {
attraction: false, attraction: false,
@@ -1090,7 +1141,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 = {
@@ -1098,9 +1149,7 @@ type OverpassQueryData = {
svg: string; svg: string;
color: string; color: string;
}; };
tags: tags: Record<string, string | string[]> | Record<string, string | string[]>[];
| Record<string, string | boolean | string[]>
| Record<string, string | boolean | string[]>[];
symbol?: string; symbol?: string;
}; };
@@ -1181,6 +1230,20 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
}, },
symbol: 'Shelter', symbol: 'Shelter',
}, },
cemetery: {
icon: {
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 17v-10a6 5 0 1 1 12 0v10"/><path d="M 4 21 a 1 1 0 0 0 1 1 h 14 a 1 1 0 0 0 1-1 v -1 a 2 2 0 0 0-2-2 H6 a 2 2 0 0 0-2 2 z"/></svg>',
color: '#000000',
},
tags: [
{
landuse: 'cemetery',
},
{
amenity: 'grave_yard',
},
],
},
'fuel-station': { 'fuel-station': {
icon: { icon: {
svg: Fuel, svg: Fuel,
@@ -1217,7 +1280,25 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
color: '#000000', color: '#000000',
}, },
tags: { tags: {
barrier: true, barrier: [
'bar',
'barrier_board',
'block',
'chain',
'cycle_barrier',
'gate',
'hampshire_gate',
'horse_stile',
'kissing_gate',
'lift_gate',
'motorcycle_barrier',
'sliding_beam',
'sliding_gate',
'stile',
'swing_gate',
'turnstile',
'wicket_gate',
],
}, },
}, },
attraction: { attraction: {
@@ -1377,3 +1458,16 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
symbol: 'Anchor', symbol: 'Anchor',
}, },
}; };
export const terrainSources: { [key: string]: RasterDEMSourceSpecification } = {
'maptiler-dem': {
type: 'raster-dem',
url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${maptilerKeyPlaceHolder}`,
},
mapterhorn: {
type: 'raster-dem',
url: 'https://tiles.mapterhorn.com/tilejson.json',
},
};
export const defaultTerrainSource = 'maptiler-dem';
+7 -2
View File
@@ -1,6 +1,5 @@
import { import {
Landmark, Landmark,
Icon,
Shell, Shell,
Bike, Bike,
Building, Building,
@@ -29,6 +28,7 @@ import {
TriangleAlert, TriangleAlert,
Anchor, Anchor,
Toilet, Toilet,
X,
type IconProps, type IconProps,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { import {
@@ -61,6 +61,7 @@ import {
TriangleAlert as TriangleAlertSvg, TriangleAlert as TriangleAlertSvg,
Anchor as AnchorSvg, Anchor as AnchorSvg,
Toilet as ToiletSvg, Toilet as ToiletSvg,
X as XSvg,
} from 'lucide-static'; } from 'lucide-static';
import type { Component } from 'svelte'; import type { Component } from 'svelte';
@@ -87,7 +88,11 @@ export const symbols: { [key: string]: Symbol } = {
icon: ShoppingBasket, icon: ShoppingBasket,
iconSvg: ShoppingBasketSvg, iconSvg: ShoppingBasketSvg,
}, },
crossing: { value: 'Crossing' }, crossing: {
value: 'Crossing',
icon: X,
iconSvg: XSvg,
},
department_store: { department_store: {
value: 'Department Store', value: 'Department Store',
icon: ShoppingBasket, icon: ShoppingBasket,
+2 -10
View File
@@ -18,7 +18,7 @@
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE" href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
target="_blank" target="_blank"
> >
MIT © 2025 gpx.studio MIT © 2026 gpx.studio
</Button> </Button>
<LanguageSelect class="w-40 mt-3" /> <LanguageSelect class="w-40 mt-3" />
</div> </div>
@@ -34,6 +34,7 @@
{i18n._('homepage.home')} {i18n._('homepage.home')}
</Button> </Button>
<Button <Button
data-sveltekit-reload
variant="link" variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground" class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/app')} href={getURLForLanguage(i18n.lang, '/app')}
@@ -70,15 +71,6 @@
<Logo company="facebook" class="h-4 fill-muted-foreground" /> <Logo company="facebook" class="h-4 fill-muted-foreground" />
{i18n._('homepage.facebook')} {i18n._('homepage.facebook')}
</Button> </Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://x.com/gpxstudio"
target="_blank"
>
<Logo company="x" class="h-4 fill-muted-foreground" />
{i18n._('homepage.x')}
</Button>
<Button <Button
variant="link" variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground" class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
+14 -18
View File
@@ -6,7 +6,7 @@
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte'; import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import type { GPXStatistics } from 'gpx'; import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import type { Readable } from 'svelte/store'; import type { Readable } from 'svelte/store';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
@@ -18,39 +18,39 @@
orientation, orientation,
panelSize, panelSize,
}: { }: {
gpxStatistics: Readable<GPXStatistics>; gpxStatistics: Readable<GPXStatisticsGroup>;
slicedGPXStatistics: Readable<[GPXStatistics, number, number] | undefined>; slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
orientation: 'horizontal' | 'vertical'; orientation: 'horizontal' | 'vertical';
panelSize: number; panelSize: number;
} = $props(); } = $props();
let statistics = $derived( let statistics = $derived(
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics $slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics.global
); );
</script> </script>
<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-10'} border-none shadow-none p-0 text-sm sm:text-base"
> >
<Card.Content <Card.Content
class="h-full flex {orientation === 'vertical' class="h-full flex {orientation === 'vertical'
? 'flex-col justify-center' ? 'flex-col justify-center'
: 'flex-row w-full justify-between'} gap-4 p-0" : 'flex-row w-full justify-evenly'} gap-4 p-0"
> >
<Tooltip label={i18n._('quantities.distance')}> <Tooltip label={i18n._('quantities.distance')}>
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" /> <Ruler size="16" class="mr-1" />
<WithUnits value={statistics.global.distance.total} type="distance" /> <WithUnits value={statistics.distance.total} type="distance" />
</span> </span>
</Tooltip> </Tooltip>
<Tooltip label={i18n._('quantities.elevation_gain_loss')}> <Tooltip label={i18n._('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" /> <MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.global.elevation.gain} type="elevation" /> <WithUnits value={statistics.elevation.gain} type="elevation" />
<MoveDownRight size="16" class="mx-1" /> <MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.global.elevation.loss} type="elevation" /> <WithUnits value={statistics.elevation.loss} type="elevation" />
</span> </span>
</Tooltip> </Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'} {#if panelSize > 120 || orientation === 'horizontal'}
@@ -64,13 +64,9 @@
> >
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Zap size="16" class="mr-1" /> <Zap size="16" class="mr-1" />
<WithUnits <WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
value={statistics.global.speed.moving}
type="speed"
showUnits={false}
/>
<span class="mx-1">/</span> <span class="mx-1">/</span>
<WithUnits value={statistics.global.speed.total} type="speed" /> <WithUnits value={statistics.speed.total} type="speed" />
</span> </span>
</Tooltip> </Tooltip>
{/if} {/if}
@@ -83,9 +79,9 @@
> >
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Timer size="16" class="mr-1" /> <Timer size="16" class="mr-1" />
<WithUnits value={statistics.global.time.moving} type="time" /> <WithUnits value={statistics.time.moving} type="time" />
<span class="mx-1">/</span> <span class="mx-1">/</span>
<WithUnits value={statistics.global.time.total} type="time" /> <WithUnits value={statistics.time.total} type="time" />
</span> </span>
</Tooltip> </Tooltip>
{/if} {/if}
+12 -5
View File
@@ -1,16 +1,23 @@
<script lang="ts"> <script lang="ts">
import { CircleQuestionMark } from '@lucide/svelte'; import { CircleQuestionMark } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import type { Snippet } from 'svelte';
export let link: string | undefined = undefined; let {
link,
class: className = '',
children,
}: {
link: string;
class?: string;
children: Snippet;
} = $props();
</script> </script>
<div <div class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {className}">
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
>
<CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" /> <CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" />
<div> <div>
<slot /> {@render children()}
{#if link} {#if link}
<a href={link} target="_blank" class="text-sm text-link hover:underline"> <a href={link} target="_blank" class="text-sm text-link hover:underline">
{i18n._('menu.more')} {i18n._('menu.more')}
+4 -14
View File
@@ -8,7 +8,7 @@
...others ...others
}: { }: {
iconOnly?: boolean; iconOnly?: boolean;
company?: 'gpx.studio' | 'mapbox' | 'github' | 'crowdin' | 'facebook' | 'x' | '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'}
@@ -55,16 +55,6 @@
d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z" d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
/></svg /></svg
> >
{:else if company === 'x'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {others.class ?? ''}"
><title>X</title><path
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
/></svg
>
{:else if company === 'reddit'} {:else if company === 'reddit'}
<svg <svg
role="img" role="img"
+14
View File
@@ -538,6 +538,7 @@
let targetInput = let targetInput =
e && e &&
e.target && e.target &&
e.target instanceof HTMLElement &&
(e.target.tagName === 'INPUT' || (e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' || e.target.tagName === 'TEXTAREA' ||
e.target.tagName === 'SELECT' || e.target.tagName === 'SELECT' ||
@@ -644,6 +645,19 @@
} else if (e.key === 'F5') { } else if (e.key === 'F5') {
$routing = !$routing; $routing = !$routing;
e.preventDefault(); e.preventDefault();
} else if (
e.key === 'ArrowRight' ||
e.key === 'ArrowDown' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowUp'
) {
if (!targetInput) {
selection.updateFromKey(
e.key === 'ArrowRight' || e.key === 'ArrowDown',
e.shiftKey
);
e.preventDefault();
}
} }
}} }}
on:dragover={(e) => e.preventDefault()} on:dragover={(e) => e.preventDefault()}
+1
View File
@@ -23,6 +23,7 @@
{i18n._('homepage.home')} {i18n._('homepage.home')}
</Button> </Button>
<Button <Button
data-sveltekit-reload
variant="link" variant="link"
class="text-base px-0 has-[>svg]:px-0" class="text-base px-0 has-[>svg]:px-0"
href={getURLForLanguage(i18n.lang, '/app')} href={getURLForLanguage(i18n.lang, '/app')}
@@ -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 { GPXStatistics } 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<GPXStatistics>; gpxStatistics: Readable<GPXStatisticsGroup>;
slicedGPXStatistics: Writable<[GPXStatistics, 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,7 +64,7 @@
}); });
</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}
@@ -14,11 +14,14 @@ import {
getTemperatureWithUnits, getTemperatureWithUnits,
getVelocityWithUnits, getVelocityWithUnits,
} from '$lib/units'; } from '$lib/units';
import Chart from 'chart.js/auto'; import Chart, {
import mapboxgl from 'mapbox-gl'; type ChartEvent,
type ChartOptions,
type ScriptableLineSegmentContext,
type TooltipItem,
} from 'chart.js/auto';
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 { GPXStatistics } 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';
@@ -27,22 +30,37 @@ const { distanceUnits, velocityUnits, temperatureUnits } = settings;
Chart.defaults.font.family = Chart.defaults.font.family =
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font 'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
interface ElevationProfilePoint {
x: number;
y: number;
time?: Date;
slope: {
at: number;
segment: number;
length: number;
};
extensions: Record<string, any>;
coordinates: Coordinates;
index: number;
}
export class ElevationProfile { 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<GPXStatistics>; private _gpxStatistics: Readable<GPXStatisticsGroup>;
private _slicedGPXStatistics: Writable<[GPXStatistics, 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<GPXStatistics>, gpxStatistics: Readable<GPXStatisticsGroup>,
slicedGPXStatistics: Writable<[GPXStatistics, 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,
@@ -50,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();
@@ -90,7 +103,7 @@ export class ElevationProfile {
} }
initialize() { initialize() {
let options = { let options: ChartOptions<'line'> = {
animation: false, animation: false,
parsing: false, parsing: false,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -98,8 +111,8 @@ export class ElevationProfile {
x: { x: {
type: 'linear', type: 'linear',
ticks: { ticks: {
callback: function (value: number) { callback: function (value: number | string) {
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`; return `${(value as number).toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
}, },
align: 'inner', align: 'inner',
maxRotation: 0, maxRotation: 0,
@@ -108,8 +121,8 @@ export class ElevationProfile {
y: { y: {
type: 'linear', type: 'linear',
ticks: { ticks: {
callback: function (value: number) { callback: function (value: number | string) {
return getElevationWithUnits(value, false); return getElevationWithUnits(value as number, false);
}, },
}, },
}, },
@@ -140,17 +153,13 @@ export class ElevationProfile {
title: () => { title: () => {
return ''; return '';
}, },
label: (context: Chart.TooltipContext) => { label: (context: TooltipItem<'line'>) => {
let point = context.raw; 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) {
@@ -165,10 +174,10 @@ export class ElevationProfile {
return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`; return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`;
} }
}, },
afterBody: (contexts: Chart.TooltipContext[]) => { afterBody: (contexts: TooltipItem<'line'>[]) => {
let context = contexts.filter((context) => context.datasetIndex === 0); let context = contexts.filter((context) => context.datasetIndex === 0);
if (context.length === 0) return; if (context.length === 0) return;
let point = context[0].raw; let point = context[0].raw as ElevationProfilePoint;
let slope = { let slope = {
at: point.slope.at.toFixed(1), at: point.slope.at.toFixed(1),
segment: point.slope.segment.toFixed(1), segment: point.slope.segment.toFixed(1),
@@ -227,6 +236,7 @@ export class ElevationProfile {
onPanStart: () => { onPanStart: () => {
this._panning = true; this._panning = true;
this._slicedGPXStatistics.set(undefined); this._slicedGPXStatistics.set(undefined);
return true;
}, },
onPanComplete: () => { onPanComplete: () => {
this._panning = false; this._panning = false;
@@ -238,13 +248,13 @@ export class ElevationProfile {
}, },
mode: 'x', mode: 'x',
onZoomStart: ({ chart, event }: { chart: Chart; event: any }) => { onZoomStart: ({ chart, event }: { chart: Chart; event: any }) => {
if (!this._chart) {
return false;
}
const maxZoom = this._chart.getInitialScaleBounds()?.x?.max ?? 0;
if ( if (
event.deltaY < 0 && event.deltaY < 0 &&
Math.abs( Math.abs(maxZoom / this._chart.getZoomLevel()) < 0.01
this._chart.getInitialScaleBounds().x.max /
this._chart.options.plugins.zoom.limits.x.minRange -
this._chart.getZoomLevel()
) < 0.01
) { ) {
// Disable wheel pan if zoomed in to the max, and zooming in // Disable wheel pan if zoomed in to the max, and zooming in
return false; return false;
@@ -262,7 +272,6 @@ export class ElevationProfile {
}, },
}, },
}, },
stacked: false,
onResize: () => { onResize: () => {
this.updateOverlay(); this.updateOverlay();
}, },
@@ -270,7 +279,7 @@ export class ElevationProfile {
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power']; let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
datasets.forEach((id) => { datasets.forEach((id) => {
options.scales[`y${id}`] = { options.scales![`y${id}`] = {
type: 'linear', type: 'linear',
position: 'right', position: 'right',
grid: { grid: {
@@ -291,12 +300,9 @@ export class ElevationProfile {
{ {
id: 'toggleMarker', id: 'toggleMarker',
events: ['mouseout'], events: ['mouseout'],
afterEvent: (chart: Chart, args: { event: Chart.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();
}
} }
}, },
}, },
@@ -305,7 +311,7 @@ export class ElevationProfile {
let startIndex = 0; let startIndex = 0;
let endIndex = 0; let endIndex = 0;
const getIndex = (evt) => { const getIndex = (evt: PointerEvent) => {
if (!this._chart) { if (!this._chart) {
return undefined; return undefined;
} }
@@ -323,22 +329,22 @@ export class ElevationProfile {
if (evt.x - rect.left <= this._chart.chartArea.left) { if (evt.x - rect.left <= this._chart.chartArea.left) {
return 0; return 0;
} else if (evt.x - rect.left >= this._chart.chartArea.right) { } else if (evt.x - rect.left >= this._chart.chartArea.right) {
return get(this._gpxStatistics).local.points.length - 1; return this._chart.data.datasets[0].data.length - 1;
} else { } else {
return undefined; return undefined;
} }
} }
let point = points.find((point) => point.element.raw); const point = points.find((point) => (point.element as any).raw);
if (point) { if (point) {
return point.element.raw.index; return (point.element as any).raw.index;
} else { } else {
return points[0].index; return points[0].index;
} }
}; };
let dragStarted = false; let dragStarted = false;
const onMouseDown = (evt) => { const onMouseDown = (evt: PointerEvent) => {
if (evt.shiftKey) { if (evt.shiftKey) {
// Panning interaction // Panning interaction
return; return;
@@ -347,7 +353,7 @@ export class ElevationProfile {
this._canvas.style.cursor = 'col-resize'; this._canvas.style.cursor = 'col-resize';
startIndex = getIndex(evt); startIndex = getIndex(evt);
}; };
const onMouseMove = (evt) => { const onMouseMove = (evt: PointerEvent) => {
if (dragStarted) { if (dragStarted) {
this._dragging = true; this._dragging = true;
endIndex = getIndex(evt); endIndex = getIndex(evt);
@@ -356,7 +362,7 @@ export class ElevationProfile {
startIndex = endIndex; startIndex = endIndex;
} else if (startIndex !== endIndex) { } else if (startIndex !== endIndex) {
this._slicedGPXStatistics.set([ this._slicedGPXStatistics.set([
get(this._gpxStatistics).slice( get(this._gpxStatistics).sliced(
Math.min(startIndex, endIndex), Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex) Math.max(startIndex, endIndex)
), ),
@@ -367,7 +373,7 @@ export class ElevationProfile {
} }
} }
}; };
const onMouseUp = (evt) => { const onMouseUp = (evt: PointerEvent) => {
dragStarted = false; dragStarted = false;
this._dragging = false; this._dragging = false;
this._canvas.style.cursor = ''; this._canvas.style.cursor = '';
@@ -386,85 +392,99 @@ export class ElevationProfile {
return; return;
} }
const data = get(this._gpxStatistics); const data = get(this._gpxStatistics);
const units = {
distance: get(distanceUnits),
velocity: get(velocityUnits),
temperature: get(temperatureUnits),
};
const datasets: Array<Array<any>> = [[], [], [], [], [], []];
data.forEachTrackPoint((trkpt, distance, speed, slope, index) => {
datasets[0].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.ele ? getConvertedElevation(trkpt.ele, units.distance) : 0,
time: trkpt.time,
slope: slope,
extensions: trkpt.getExtensions(),
coordinates: trkpt.getCoordinates(),
index: index,
});
if (data.global.time.total > 0) {
datasets[1].push({
x: getConvertedDistance(distance, units.distance),
y: getConvertedVelocity(speed, units.velocity, units.distance),
index: index,
});
}
if (data.global.hr.count > 0) {
datasets[2].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getHeartRate(),
index: index,
});
}
if (data.global.cad.count > 0) {
datasets[3].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getCadence(),
index: index,
});
}
if (data.global.atemp.count > 0) {
datasets[4].push({
x: getConvertedDistance(distance, units.distance),
y: getConvertedTemperature(trkpt.getTemperature(), units.temperature),
index: index,
});
}
if (data.global.power.count > 0) {
datasets[5].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getPower(),
index: index,
});
}
});
this._chart.data.datasets[0] = { this._chart.data.datasets[0] = {
label: i18n._('quantities.elevation'), label: i18n._('quantities.elevation'),
data: data.local.points.map((point, index) => { data: datasets[0],
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.ele ? getConvertedElevation(point.ele) : 0,
time: point.time,
slope: {
at: data.local.slope.at[index],
segment: data.local.slope.segment[index],
length: data.local.slope.length[index],
},
extensions: point.getExtensions(),
coordinates: point.getCoordinates(),
index: index,
};
}),
normalized: true, normalized: true,
fill: 'start', fill: 'start',
order: 1, order: 1,
segment: {}, segment: {},
}; };
this._chart.data.datasets[1] = { this._chart.data.datasets[1] = {
data: data.local.points.map((point, index) => { data: datasets[1],
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: getConvertedVelocity(data.local.speed[index]),
index: index,
};
}),
normalized: true, normalized: true,
yAxisID: 'yspeed', yAxisID: 'yspeed',
}; };
this._chart.data.datasets[2] = { this._chart.data.datasets[2] = {
data: data.local.points.map((point, index) => { data: datasets[2],
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getHeartRate(),
index: index,
};
}),
normalized: true, normalized: true,
yAxisID: 'yhr', yAxisID: 'yhr',
}; };
this._chart.data.datasets[3] = { this._chart.data.datasets[3] = {
data: data.local.points.map((point, index) => { data: datasets[3],
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getCadence(),
index: index,
};
}),
normalized: true, normalized: true,
yAxisID: 'ycad', yAxisID: 'ycad',
}; };
this._chart.data.datasets[4] = { this._chart.data.datasets[4] = {
data: data.local.points.map((point, index) => { data: datasets[4],
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: getConvertedTemperature(point.getTemperature()),
index: index,
};
}),
normalized: true, normalized: true,
yAxisID: 'yatemp', yAxisID: 'yatemp',
}; };
this._chart.data.datasets[5] = { this._chart.data.datasets[5] = {
data: data.local.points.map((point, index) => { data: datasets[5],
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getPower(),
index: index,
};
}),
normalized: true, normalized: true,
yAxisID: 'ypower', yAxisID: 'ypower',
}; };
this._chart.options.scales.x['min'] = 0;
this._chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total); this._chart.options.scales!.x!['min'] = 0;
this._chart.options.scales!.x!['max'] = getConvertedDistance(
data.global.distance.total,
units.distance
);
this.setVisibility(); this.setVisibility();
this.setFill(); this.setFill();
@@ -513,21 +533,24 @@ export class ElevationProfile {
return; return;
} }
const elevationFill = get(this._elevationFill); const elevationFill = get(this._elevationFill);
const dataset = this._chart.data.datasets[0];
let segment: any = {};
if (elevationFill === 'slope') { if (elevationFill === 'slope') {
this._chart.data.datasets[0]['segment'] = { segment = {
backgroundColor: this.slopeFillCallback, backgroundColor: this.slopeFillCallback,
}; };
} else if (elevationFill === 'surface') { } else if (elevationFill === 'surface') {
this._chart.data.datasets[0]['segment'] = { segment = {
backgroundColor: this.surfaceFillCallback, backgroundColor: this.surfaceFillCallback,
}; };
} else if (elevationFill === 'highway') { } else if (elevationFill === 'highway') {
this._chart.data.datasets[0]['segment'] = { segment = {
backgroundColor: this.highwayFillCallback, backgroundColor: this.highwayFillCallback,
}; };
} else { } else {
this._chart.data.datasets[0]['segment'] = {}; segment = {};
} }
Object.assign(dataset, { segment });
} }
updateOverlay() { updateOverlay() {
@@ -554,10 +577,12 @@ export class ElevationProfile {
const gpxStatistics = get(this._gpxStatistics); const gpxStatistics = get(this._gpxStatistics);
let startPixel = this._chart.scales.x.getPixelForValue( let startPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(gpxStatistics.local.distance.total[startIndex]) getConvertedDistance(
gpxStatistics.getTrackPoint(startIndex)?.distance.total ?? 0
)
); );
let endPixel = this._chart.scales.x.getPixelForValue( let endPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(gpxStatistics.local.distance.total[endIndex]) getConvertedDistance(gpxStatistics.getTrackPoint(endIndex)?.distance.total ?? 0)
); );
selectionContext.fillRect( selectionContext.fillRect(
@@ -575,19 +600,22 @@ export class ElevationProfile {
} }
} }
slopeFillCallback(context) { slopeFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
return getSlopeColor(context.p0.raw.slope.segment); const point = context.p0.raw as ElevationProfilePoint;
return getSlopeColor(point.slope.segment);
} }
surfaceFillCallback(context) { surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
return getSurfaceColor(context.p0.raw.extensions.surface); const point = context.p0.raw as ElevationProfilePoint;
return getSurfaceColor(point.extensions.surface);
} }
highwayFillCallback(context) { highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
const point = context.p0.raw as ElevationProfilePoint;
return getHighwayColor( return getHighwayColor(
context.p0.raw.extensions.highway, point.extensions.highway,
context.p0.raw.extensions.sac_scale, point.extensions.sac_scale,
context.p0.raw.extensions.mtb_scale point.extensions.mtb_scale
); );
} }
@@ -596,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,10 +16,11 @@
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';
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
let { let {
useHash = true, useHash = true,
@@ -32,6 +33,7 @@
const { const {
currentBasemap, currentBasemap,
selectedBasemapTree,
distanceUnits, distanceUnits,
velocityUnits, velocityUnits,
temperatureUnits, temperatureUnits,
@@ -66,6 +68,9 @@
if (allowedEmbeddingBasemaps.includes(options.basemap)) { if (allowedEmbeddingBasemaps.includes(options.basemap)) {
$currentBasemap = options.basemap; $currentBasemap = options.basemap;
} }
if (!isSelected($selectedBasemapTree, options.basemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, options.basemap);
}
$distanceMarkers = options.distanceMarkers; $distanceMarkers = options.distanceMarkers;
$directionMarkers = options.directionMarkers; $directionMarkers = options.directionMarkers;
$distanceUnits = options.distanceUnits; $distanceUnits = options.distanceUnits;
@@ -97,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}
@@ -125,6 +130,7 @@
<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: 'maptilerTopo',
elevation: { elevation: {
show: true, show: true,
height: 170, height: 170,
@@ -107,7 +107,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 +123,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') {
+10 -10
View File
@@ -21,7 +21,7 @@
SquareActivity, SquareActivity,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { GPXStatistics } from 'gpx'; import { GPXGlobalStatistics } from 'gpx';
import { ListRootItem } from '$lib/components/file-list/file-list'; import { ListRootItem } from '$lib/components/file-list/file-list';
import { fileStateCollection } from '$lib/logic/file-state'; import { fileStateCollection } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection'; import { selection } from '$lib/logic/selection';
@@ -48,24 +48,24 @@
extensions: false, extensions: false,
}; };
} else { } else {
let statistics = $gpxStatistics; let statistics = $gpxStatistics.global;
if (exportState.current === ExportState.ALL) { if (exportState.current === ExportState.ALL) {
statistics = Array.from(get(fileStateCollection).values()) statistics = Array.from(get(fileStateCollection).values())
.map((file) => file.statistics) .map((file) => file.statistics)
.reduce((acc, cur) => { .reduce((acc, cur) => {
if (cur !== undefined) { if (cur !== undefined) {
acc.mergeWith(cur.getStatisticsFor(new ListRootItem())); acc.mergeWith(cur.getStatisticsFor(new ListRootItem()).global);
} }
return acc; return acc;
}, new GPXStatistics()); }, new GPXGlobalStatistics());
} }
return { return {
time: statistics.global.time.total === 0, time: statistics.time.total === 0,
hr: statistics.global.hr.count === 0, hr: statistics.hr.count === 0,
cad: statistics.global.cad.count === 0, cad: statistics.cad.count === 0,
atemp: statistics.global.atemp.count === 0, atemp: statistics.atemp.count === 0,
power: statistics.global.power.count === 0, power: statistics.power.count === 0,
extensions: Object.keys(statistics.global.extensions).length === 0, extensions: Object.keys(statistics.extensions).length === 0,
}; };
} }
}); });
@@ -45,7 +45,7 @@
<ScrollArea <ScrollArea
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}" class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
{orientation} {orientation}
scrollbarXClasses={orientation === 'vertical' ? '' : 'mt-1 h-2'} scrollbarXClasses={orientation === 'vertical' ? '' : 'hidden'}
scrollbarYClasses={orientation === 'vertical' ? '' : ''} scrollbarYClasses={orientation === 'vertical' ? '' : ''}
> >
<div <div
@@ -121,20 +121,16 @@
} }
.vertical :global(button) { .vertical :global(button) {
@apply hover:bg-muted; @apply hover:bg-[var(--selection)];
}
.vertical :global(.sortable-selected button) {
@apply hover:bg-accent;
} }
.vertical :global(.sortable-selected) { .vertical :global(.sortable-selected) {
@apply bg-accent; @apply bg-[var(--selection)];
} }
.horizontal :global(button) { .horizontal :global(button) {
@apply bg-accent; @apply bg-[var(--selection)];
@apply hover:bg-muted; @apply hover:bg-background;
} }
.horizontal :global(.sortable-selected button) { .horizontal :global(.sortable-selected button) {
@@ -34,11 +34,10 @@
import { editStyle } from '$lib/components/file-list/style/utils.svelte'; import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { getSymbolKey, symbols } from '$lib/assets/symbols'; import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { selection, copied, cut } from '$lib/logic/selection'; import { selection, copied, cut } from '$lib/logic/selection';
import { map } from '$lib/components/map/map';
import { fileActions, pasteSelection } from '$lib/logic/file-actions'; import { fileActions, pasteSelection } from '$lib/logic/file-actions';
import { allHidden } from '$lib/logic/hidden'; import { allHidden } from '$lib/logic/hidden';
import { boundsManager } from '$lib/logic/bounds'; import { boundsManager } from '$lib/logic/bounds';
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers'; import { gpxColors, gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
import { fileStateCollection } from '$lib/logic/file-state'; import { fileStateCollection } from '$lib/logic/file-state';
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup'; import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { allowedPastes } from './sortable-file-list'; import { allowedPastes } from './sortable-file-list';
@@ -58,41 +57,31 @@
let singleSelection = $derived($selection.size === 1); let singleSelection = $derived($selection.size === 1);
let nodeColors: string[] = $state([]); let nodeColors: string[] = $derived.by(() => {
$effect.pre(() => {
let colors: string[] = []; let colors: string[] = [];
if (node && $map) { if (node) {
if (node instanceof GPXFile) { if (node instanceof GPXFile) {
let defaultColor = undefined; let defaultColor = $gpxColors.get(item.getFileId());
let layer = gpxLayers.getLayer(item.getFileId());
if (layer) {
defaultColor = layer.layerColor;
}
let style = node.getStyle(defaultColor); let style = node.getStyle(defaultColor);
style.color.forEach((c) => { colors = style.color;
if (!colors.includes(c)) {
colors.push(c);
}
});
} else if (node instanceof Track) { } else if (node instanceof Track) {
let style = node.getStyle(); let style = node.getStyle();
if (style) { if (
if (style['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) { style &&
style['gpx_style:color'] &&
!colors.includes(style['gpx_style:color'])
) {
colors.push(style['gpx_style:color']); colors.push(style['gpx_style:color']);
} }
}
if (colors.length === 0) { if (colors.length === 0) {
let layer = gpxLayers.getLayer(item.getFileId()); let defaultColor = $gpxColors.get(item.getFileId());
if (layer) { if (defaultColor) {
colors.push(layer.layerColor); colors.push(defaultColor);
} }
} }
} }
} }
nodeColors = colors; return colors;
}); });
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined); let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
@@ -175,7 +164,7 @@
let file = fileStateCollection.getFile(item.getFileId()); let file = fileStateCollection.getFile(item.getFileId());
if (layer && file) { if (layer && file) {
let waypoint = file.wpt[item.getWaypointIndex()]; let waypoint = file.wpt[item.getWaypointIndex()];
if (waypoint) { if (waypoint && !waypoint._data.hidden) {
waypointPopup?.setItem({ waypointPopup?.setItem({
item: waypoint, item: waypoint,
fileId: item.getFileId(), fileId: item.getFileId(),
@@ -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(PUBLIC_MAPBOX_TOKEN, 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;
@@ -16,7 +16,8 @@
</script> </script>
<Button <Button
class="p-1 has-[>svg]:px-2 h-8 justify-start {className}" size="sm"
class="justify-start {className}"
variant="outline" variant="outline"
onclick={() => { onclick={() => {
navigator.clipboard.writeText( navigator.clipboard.writeText(
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy } from 'svelte';
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers'; import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
import { DistanceMarkers } from '$lib/components/map/gpx-layer/distance-markers'; import { DistanceMarkers } from '$lib/components/map/gpx-layer/distance-markers';
import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-markers'; import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-markers';
@@ -9,13 +9,10 @@
let distanceMarkers: DistanceMarkers; let distanceMarkers: DistanceMarkers;
let startEndMarkers: StartEndMarkers; let startEndMarkers: StartEndMarkers;
onMount(() => { map.onLoad((map_) => {
gpxLayers.init(); gpxLayers.init();
startEndMarkers = new StartEndMarkers(); startEndMarkers = new StartEndMarkers();
distanceMarkers = new DistanceMarkers(); distanceMarkers = new DistanceMarkers();
});
map.onLoad((map_) => {
createPopups(map_); createPopups(map_);
}); });
@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { TrackPoint } from 'gpx'; import type { TrackPoint } from 'gpx';
import { Button } from '$lib/components/ui/button';
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte'; import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import WithUnits from '$lib/components/WithUnits.svelte'; import WithUnits from '$lib/components/WithUnits.svelte';
import { Compass, Mountain, Timer } from '@lucide/svelte'; import { Compass, Earth, Mountain, Timer } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import type { PopupItem } from '$lib/components/map/map-popup'; import type { PopupItem } from '$lib/components/map/map-popup';
import { map } from '$lib/components/map/map';
let { trackpoint }: { trackpoint: PopupItem<TrackPoint> } = $props(); let { trackpoint }: { trackpoint: PopupItem<TrackPoint> } = $props();
</script> </script>
@@ -35,5 +37,17 @@
onCopy={() => trackpoint.hide?.()} onCopy={() => trackpoint.hide?.()}
class="mt-0.5" class="mt-0.5"
/> />
{#if trackpoint.fileId === undefined}
<Button
size="sm"
variant="outline"
class="justify-start"
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.getLatitude().toFixed(5)}/${trackpoint.item.getLongitude().toFixed(5)}`}
target="_blank"
>
<Earth size="14" />
{i18n._('menu.edit_osm')}
</Button>
{/if}
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
@@ -13,6 +13,8 @@
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { fileActions } from '$lib/logic/file-actions'; import { fileActions } from '$lib/logic/file-actions';
import type { PopupItem } from '$lib/components/map/map-popup'; import type { PopupItem } from '$lib/components/map/map-popup';
import { selection } from '$lib/logic/selection';
import { ListFileItem } from '$lib/components/file-list/file-list';
let { let {
waypoint, waypoint,
@@ -20,6 +22,9 @@
waypoint: PopupItem<Waypoint>; waypoint: PopupItem<Waypoint>;
} = $props(); } = $props();
let selected = $derived(
waypoint.fileId ? $selection.hasAnyChildren(new ListFileItem(waypoint.fileId)) : false
);
let symbolKey = $derived(waypoint ? getSymbolKey(waypoint.item.sym) : undefined); let symbolKey = $derived(waypoint ? getSymbolKey(waypoint.item.sym) : undefined);
function sanitize(text: string | undefined): string { function sanitize(text: string | undefined): string {
@@ -81,7 +86,7 @@
</ScrollArea> </ScrollArea>
<div class="mt-2 flex flex-col gap-1"> <div class="mt-2 flex flex-col gap-1">
<CopyCoordinates coordinates={waypoint.item.attributes} /> <CopyCoordinates coordinates={waypoint.item.attributes} />
{#if $currentTool === Tool.WAYPOINT} {#if $currentTool === Tool.WAYPOINT && selected}
<Button <Button
class="p-1 has-[>svg]:px-2 h-8" class="p-1 has-[>svg]:px-2 h-8"
variant="outline" variant="outline"
@@ -1,21 +1,15 @@
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 { 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 '../style';
const { distanceMarkers, distanceUnits } = settings; const { distanceMarkers, distanceUnits } = settings;
const stops = [ const levels = [100, 50, 25, 10, 5, 1];
[100, 0],
[50, 7],
[25, 8, 10],
[10, 10],
[5, 11],
[1, 13],
];
export class DistanceMarkers { export class DistanceMarkers {
updateBinded: () => void = this.update.bind(this); updateBinded: () => void = this.update.bind(this);
@@ -29,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);
} }
}) })
); );
@@ -50,22 +44,33 @@ export class DistanceMarkers {
data: this.getDistanceMarkersGeoJSON(), data: this.getDistanceMarkersGeoJSON(),
}); });
} }
stops.forEach(([d, minzoom, maxzoom]) => { if (!map_.getLayer('distance-markers')) {
if (!map_.getLayer(`distance-markers-${d}`)) { map_.addLayer(
map_.addLayer({ {
id: `distance-markers-${d}`, id: 'distance-markers',
type: 'symbol', type: 'symbol',
source: 'distance-markers', source: 'distance-markers',
filter: filter: [
d === 5 'match',
? [ ['get', 'level'],
100,
['>=', ['zoom'], 0],
50,
['>=', ['zoom'], 7],
25,
[
'any', 'any',
['==', ['get', 'level'], 5], ['all', ['>=', ['zoom'], 8], ['<=', ['zoom'], 9]],
['==', ['get', 'level'], 25], ['>=', ['zoom'], 11],
] ],
: ['==', ['get', 'level'], d], 10,
minzoom: minzoom, ['>=', ['zoom'], 10],
maxzoom: maxzoom ?? 24, 5,
['>=', ['zoom'], 11],
1,
['>=', ['zoom'], 13],
false,
],
layout: { layout: {
'text-field': ['get', 'distance'], 'text-field': ['get', 'distance'],
'text-size': 14, 'text-size': 14,
@@ -76,17 +81,14 @@ export class DistanceMarkers {
'text-halo-width': 2, 'text-halo-width': 2,
'text-halo-color': 'white', 'text-halo-color': 'white',
}, },
}); },
} else { ANCHOR_LAYER_KEY.distanceMarkers
map_.moveLayer(`distance-markers-${d}`); );
} }
});
} else { } else {
stops.forEach(([d]) => { if (map_.getLayer('distance-markers')) {
if (map_.getLayer(`distance-markers-${d}`)) { map_.removeLayer('distance-markers');
map_.removeLayer(`distance-markers-${d}`);
} }
});
} }
} 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
@@ -101,35 +103,26 @@ export class DistanceMarkers {
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection { getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
let statistics = get(gpxStatistics); let statistics = get(gpxStatistics);
let features = []; let features: GeoJSON.Feature[] = [];
let currentTargetDistance = 1; let currentTargetDistance = 1;
for (let i = 0; i < statistics.local.distance.total.length; i++) { statistics.forEachTrackPoint((trkpt, dist) => {
if ( if (dist >= getConvertedDistanceToKilometers(currentTargetDistance)) {
statistics.local.distance.total[i] >=
getConvertedDistanceToKilometers(currentTargetDistance)
) {
let distance = currentTargetDistance.toFixed(0); let distance = currentTargetDistance.toFixed(0);
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [ let level = levels.find((level) => currentTargetDistance % level === 0) || 1;
0, 0,
];
features.push({ features.push({
type: 'Feature', type: 'Feature',
geometry: { geometry: {
type: 'Point', type: 'Point',
coordinates: [ coordinates: [trkpt.getLongitude(), trkpt.getLatitude()],
statistics.local.points[i].getLongitude(),
statistics.local.points[i].getLatitude(),
],
}, },
properties: { properties: {
distance, distance,
level, level,
minzoom,
}, },
} as GeoJSON.Feature); } as GeoJSON.Feature);
currentTargetDistance += 1; currentTargetDistance += 1;
} }
} });
return { return {
type: 'FeatureCollection', type: 'FeatureCollection',
@@ -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,5 +1,10 @@
import { get, type Readable } from 'svelte/store'; import { get, type Readable } from 'svelte/store';
import mapboxgl from 'mapbox-gl'; import maplibregl, {
type GeoJSONSource,
type FilterSpecification,
type MapLayerMouseEvent,
type MapLayerTouchEvent,
} from 'maplibre-gl';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { waypointPopup, trackpointPopup } from './gpx-layer-popup'; import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
import { import {
@@ -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,8 @@ 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 './gpx-layers';
const colors = [ const colors = [
'#ff0000', '#ff0000',
@@ -43,26 +50,49 @@ for (let color of colors) {
} }
// Get the color with the least amount of uses // Get the color with the least amount of uses
function getColor() { function getColor(fileId: string) {
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b)); let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
colorCount[color]++; colorCount[color]++;
gpxColors.update((colors) => {
colors.set(fileId, color);
return colors;
});
return color; return color;
} }
function decrementColor(color: string) { function replaceColor(fileId: string, oldColor: string, newColor: string) {
if (colorCount.hasOwnProperty(oldColor)) {
colorCount[oldColor]--;
}
colorCount[newColor]++;
gpxColors.update((colors) => {
colors.set(fileId, newColor);
return colors;
});
}
function removeColor(fileId: string, color: string) {
if (colorCount.hasOwnProperty(color)) { if (colorCount.hasOwnProperty(color)) {
colorCount[color]--; colorCount[color]--;
} }
gpxColors.update((colors) => {
colors.delete(fileId);
return colors;
});
} }
function getMarkerForSymbol(symbol: string | undefined, layerColor: string) { export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined; let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
${Square.replace('width="24"', 'width="12"') ${
layerColor
? Square.replace('width="24"', 'width="12"')
.replace('height="24"', 'height="12"') .replace('height="24"', 'height="12"')
.replace('stroke="currentColor"', 'stroke="SteelBlue"') .replace('stroke="currentColor"', 'stroke="SteelBlue"')
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"') .replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
.replace('fill="none"', `fill="${layerColor}"`)} .replace('fill="none"', `fill="${layerColor}"`)
: ''
}
${MapPin.replace('width="24"', '') ${MapPin.replace('width="24"', '')
.replace('height="24"', '') .replace('height="24"', '')
.replace('stroke="currentColor"', '') .replace('stroke="currentColor"', '')
@@ -87,26 +117,41 @@ export class GPXLayer {
fileId: string; fileId: string;
file: Readable<GPXFileWithStatistics | undefined>; file: Readable<GPXFileWithStatistics | undefined>;
layerColor: string; layerColor: string;
markers: mapboxgl.Marker[] = [];
selected: boolean = false; selected: boolean = false;
draggable: boolean; currentWaypointData: GeoJSON.FeatureCollection | null = null;
draggedWaypointIndex: number | null = null;
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: MapLayerMouseEvent) => void =
this.waypointLayerOnMouseEnter.bind(this);
waypointLayerOnMouseLeaveBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnMouseLeave.bind(this);
waypointLayerOnClickBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnClick.bind(this);
waypointLayerOnMouseDownBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnMouseDown.bind(this);
waypointLayerOnTouchStartBinded: (e: MapLayerTouchEvent) => void =
this.waypointLayerOnTouchStart.bind(this);
waypointLayerOnMouseMoveBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
this.waypointLayerOnMouseMove.bind(this);
waypointLayerOnMouseUpBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
this.waypointLayerOnMouseUp.bind(this);
constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) { constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
this.fileId = fileId; this.fileId = fileId;
this.file = file; this.file = file;
this.layerColor = getColor(); this.layerColor = getColor(fileId);
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();
} }
}) })
@@ -125,24 +170,13 @@ export class GPXLayer {
}) })
); );
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded)); this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
this.unsubscribe.push(
currentTool.subscribe((tool) => {
if (tool === Tool.WAYPOINT && !this.draggable) {
this.draggable = true;
this.markers.forEach((marker) => marker.setDraggable(true));
} else if (tool !== Tool.WAYPOINT && this.draggable) {
this.draggable = false;
this.markers.forEach((marker) => marker.setDraggable(false));
}
})
);
this.draggable = get(currentTool) === Tool.WAYPOINT;
} }
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;
} }
@@ -151,12 +185,14 @@ export class GPXLayer {
file._data.style.color && file._data.style.color &&
this.layerColor !== `#${file._data.style.color}` this.layerColor !== `#${file._data.style.color}`
) { ) {
decrementColor(this.layerColor); replaceColor(this.fileId, this.layerColor, `#${file._data.style.color}`);
this.layerColor = `#${file._data.style.color}`; this.layerColor = `#${file._data.style.color}`;
} }
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 {
@@ -167,7 +203,8 @@ export class GPXLayer {
} }
if (!_map.getLayer(this.fileId)) { if (!_map.getLayer(this.fileId)) {
_map.addLayer({ _map.addLayer(
{
id: this.fileId, id: this.fileId,
type: 'line', type: 'line',
source: this.fileId, source: this.fileId,
@@ -180,15 +217,31 @@ export class GPXLayer {
'line-width': ['get', 'width'], 'line-width': ['get', 'width'],
'line-opacity': ['get', 'opacity'], 'line-opacity': ['get', 'opacity'],
}, },
}); },
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 (get(directionMarkers)) {
if (!_map.getLayer(this.fileId + '-direction')) { if (!_map.getLayer(this.fileId + '-direction')) {
_map.addLayer( _map.addLayer(
@@ -213,172 +266,136 @@ export class GPXLayer {
'text-halo-color': 'white', 'text-halo-color': 'white',
}, },
}, },
_map.getLayer('distance-markers-100') ? 'distance-markers-100' : undefined ANCHOR_LAYER_KEY.directionMarkers
); );
} }
_map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false });
} else { } else {
if (_map.getLayer(this.fileId + '-direction')) { if (_map.getLayer(this.fileId + '-direction')) {
_map.removeLayer(this.fileId + '-direction'); _map.removeLayer(this.fileId + '-direction');
} }
} }
let visibleItems: [number, number][] = []; let waypointSource = _map.getSource(this.fileId + '-waypoints') as
file.forEachSegment((segment, trackIndex, segmentIndex) => { | GeoJSONSource
if (!segment._data.hidden) { | undefined;
visibleItems.push([trackIndex, segmentIndex]); this.currentWaypointData = this.getWaypointsGeoJSON();
if (waypointSource) {
waypointSource.setData(this.currentWaypointData);
} else {
_map.addSource(this.fileId + '-waypoints', {
type: 'geojson',
data: this.currentWaypointData,
promoteId: 'waypointIndex',
});
}
if (!_map.getLayer(this.fileId + '-waypoints')) {
_map.addLayer(
{
id: this.fileId + '-waypoints',
type: 'symbol',
source: this.fileId + '-waypoints',
layout: {
'icon-image': ['get', 'icon'],
'icon-size': 0.3,
'icon-anchor': 'bottom',
'icon-padding': 0,
'icon-allow-overlap': true,
},
},
ANCHOR_LAYER_KEY.waypoints
);
layerEventManager.on(
'mouseenter',
this.fileId + '-waypoints',
this.waypointLayerOnMouseEnterBinded
);
layerEventManager.on(
'mouseleave',
this.fileId + '-waypoints',
this.waypointLayerOnMouseLeaveBinded
);
layerEventManager.on(
'click',
this.fileId + '-waypoints',
this.waypointLayerOnClickBinded
);
layerEventManager.on(
'mousedown',
this.fileId + '-waypoints',
this.waypointLayerOnMouseDownBinded
);
layerEventManager.on(
'touchstart',
this.fileId + '-waypoints',
this.waypointLayerOnTouchStartBinded
);
}
let visibleWaypoints: number[] = [];
file.wpt.forEach((waypoint, waypointIndex) => {
if (!waypoint._data.hidden) {
visibleWaypoints.push(waypointIndex);
} }
}); });
_map.setFilter( _map.setFilter(
this.fileId, this.fileId + '-waypoints',
[ ['in', ['get', 'waypointIndex'], ['literal', visibleWaypoints]],
'any',
...visibleItems.map(([trackIndex, segmentIndex]) => [
'all',
['==', 'trackIndex', trackIndex],
['==', 'segmentIndex', segmentIndex],
]),
],
{ validate: false } { validate: false }
); );
if (_map.getLayer(this.fileId + '-direction')) {
_map.setFilter(
this.fileId + '-direction',
[
'any',
...visibleItems.map(([trackIndex, segmentIndex]) => [
'all',
['==', 'trackIndex', trackIndex],
['==', 'segmentIndex', segmentIndex],
]),
],
{ 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
return; return;
} }
let markerIndex = 0;
if (get(selection).hasAnyChildren(new ListFileItem(this.fileId))) {
file.wpt.forEach((waypoint) => {
// Update markers
let symbolKey = getSymbolKey(waypoint.sym);
if (markerIndex < this.markers.length) {
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(
symbolKey,
this.layerColor
);
this.markers[markerIndex].setLngLat(waypoint.getCoordinates());
Object.defineProperty(this.markers[markerIndex], '_waypoint', {
value: waypoint,
writable: true,
});
} else {
let element = document.createElement('div');
element.classList.add('w-8', 'h-8', 'drop-shadow-xl');
element.innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
let marker = new mapboxgl.Marker({
draggable: this.draggable,
element,
anchor: 'bottom',
}).setLngLat(waypoint.getCoordinates());
Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true });
let dragEndTimestamp = 0;
marker.getElement().addEventListener('mousemove', (e) => {
if (marker._isDragging) {
return;
}
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
e.stopPropagation();
});
marker.getElement().addEventListener('click', (e) => {
if (dragEndTimestamp && Date.now() - dragEndTimestamp < 1000) {
return;
}
if (get(currentTool) === Tool.WAYPOINT && e.shiftKey) {
fileActions.deleteWaypoint(this.fileId, marker._waypoint._data.index);
e.stopPropagation();
return;
}
if (get(treeFileView)) {
if (
(e.ctrlKey || e.metaKey) &&
get(selection).hasAnyChildren(
new ListWaypointsItem(this.fileId),
false
)
) {
selection.addSelectItem(
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
);
} else {
selection.selectItem(
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
);
}
} else if (get(currentTool) === Tool.WAYPOINT) {
selectedWaypoint.set([marker._waypoint, this.fileId]);
} else {
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
}
e.stopPropagation();
});
marker.on('dragstart', () => {
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, true);
marker.getElement().style.cursor = 'grabbing';
waypointPopup?.hide();
});
marker.on('dragend', (e) => {
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
marker.getElement().style.cursor = '';
getElevation([marker._waypoint]).then((ele) => {
fileActionManager.applyToFile(this.fileId, (file) => {
let latLng = marker.getLngLat();
let wpt = file.wpt[marker._waypoint._data.index];
wpt.setCoordinates({
lat: latLng.lat,
lon: latLng.lng,
});
wpt.ele = ele[0];
});
});
dragEndTimestamp = Date.now();
});
this.markers.push(marker);
}
markerIndex++;
});
}
while (markerIndex < this.markers.length) {
// Remove extra markers
this.markers.pop()?.remove();
}
this.markers.forEach((marker) => {
if (!marker._waypoint._data.hidden) {
marker.addTo(_map);
} else {
marker.remove();
}
});
} }
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);
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',
this.fileId + '-waypoints',
this.waypointLayerOnMouseEnterBinded
);
layerEventManager.off(
'mouseleave',
this.fileId + '-waypoints',
this.waypointLayerOnMouseLeaveBinded
);
layerEventManager.off(
'click',
this.fileId + '-waypoints',
this.waypointLayerOnClickBinded
);
layerEventManager.off(
'mousedown',
this.fileId + '-waypoints',
this.waypointLayerOnMouseDownBinded
);
layerEventManager.off(
'touchstart',
this.fileId + '-waypoints',
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');
} }
@@ -388,15 +405,17 @@ export class GPXLayer {
if (_map.getSource(this.fileId)) { if (_map.getSource(this.fileId)) {
_map.removeSource(this.fileId); _map.removeSource(this.fileId);
} }
if (_map.getLayer(this.fileId + '-waypoints')) {
_map.removeLayer(this.fileId + '-waypoints');
}
if (_map.getSource(this.fileId + '-waypoints')) {
_map.removeSource(this.fileId + '-waypoints');
}
} }
this.markers.forEach((marker) => {
marker.remove();
});
this.unsubscribe.forEach((unsubscribe) => unsubscribe()); this.unsubscribe.forEach((unsubscribe) => unsubscribe());
decrementColor(this.layerColor); removeColor(this.fileId, this.layerColor);
} }
moveToFront() { moveToFront() {
@@ -405,10 +424,13 @@ export class GPXLayer {
return; return;
} }
if (_map.getLayer(this.fileId)) { if (_map.getLayer(this.fileId)) {
_map.moveLayer(this.fileId); _map.moveLayer(this.fileId, ANCHOR_LAYER_KEY.tracks);
}
if (_map.getLayer(this.fileId + '-waypoints')) {
_map.moveLayer(this.fileId + '-waypoints', ANCHOR_LAYER_KEY.waypoints);
} }
if (_map.getLayer(this.fileId + '-direction')) { if (_map.getLayer(this.fileId + '-direction')) {
_map.moveLayer(this.fileId + '-direction'); _map.moveLayer(this.fileId + '-direction', ANCHOR_LAYER_KEY.directionMarkers);
} }
} }
@@ -449,7 +471,7 @@ export class GPXLayer {
} }
} }
layerOnClick(e: any) { 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'])
@@ -457,8 +479,8 @@ export class GPXLayer {
return; return;
} }
let trackIndex = e.features[0].properties.trackIndex; let trackIndex = e.features![0].properties!.trackIndex;
let segmentIndex = e.features[0].properties.segmentIndex; let segmentIndex = e.features![0].properties!.segmentIndex;
if ( if (
get(currentTool) === Tool.SCISSORS && get(currentTool) === Tool.SCISSORS &&
@@ -466,6 +488,11 @@ export class GPXLayer {
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
) )
) { ) {
if (get(map)?.queryRenderedFeatures(e.point, { layers: ['split-controls'] }).length) {
// Clicked on split control, ignoring
return;
}
fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, { fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, {
lat: e.lngLat.lat, lat: e.lngLat.lat,
lon: e.lngLat.lng, lon: e.lngLat.lng,
@@ -502,6 +529,179 @@ export class GPXLayer {
} }
} }
waypointLayerOnMouseEnter(e: MapLayerMouseEvent) {
if (this.draggedWaypointIndex !== null) {
return;
}
let file = get(this.file)?.file;
if (!file) {
return;
}
let waypointIndex = e.features![0].properties!.waypointIndex;
let waypoint = file.wpt[waypointIndex];
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true);
}
waypointLayerOnMouseLeave() {
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false);
}
waypointLayerOnClick(e: MapLayerMouseEvent) {
e.preventDefault();
let waypointIndex = e.features![0].properties!.waypointIndex;
let file = get(this.file)?.file;
if (!file) {
return;
}
let waypoint = file.wpt[waypointIndex];
if (get(currentTool) === Tool.WAYPOINT) {
if (this.selected) {
if (e.originalEvent.shiftKey) {
fileActions.deleteWaypoint(this.fileId, waypointIndex);
} else {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
selectedWaypoint.set([waypoint, this.fileId]);
}
} else {
if (get(treeFileView)) {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
} else {
selection.selectItem(new ListFileItem(this.fileId));
}
selectedWaypoint.set([waypoint, this.fileId]);
}
} else {
if (get(treeFileView)) {
if ((e.originalEvent.ctrlKey || e.originalEvent.metaKey) && this.selected) {
selection.addSelectItem(new ListWaypointItem(this.fileId, waypointIndex));
} else {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
}
} else {
if (!this.selected) {
selection.selectItem(new ListFileItem(this.fileId));
}
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
}
}
}
waypointLayerOnMouseDown(e: MapLayerMouseEvent) {
if (get(currentTool) !== Tool.WAYPOINT || !this.selected) {
return;
}
const _map = get(map);
if (!_map) {
return;
}
e.preventDefault();
_map.dragPan.disable();
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
this.draggingStartingPosition = e.point;
waypointPopup?.hide();
_map.on('mousemove', this.waypointLayerOnMouseMoveBinded);
_map.once('mouseup', this.waypointLayerOnMouseUpBinded);
}
waypointLayerOnTouchStart(e: MapLayerTouchEvent) {
if (e.points.length !== 1 || get(currentTool) !== Tool.WAYPOINT || !this.selected) {
return;
}
const _map = get(map);
if (!_map) {
return;
}
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
this.draggingStartingPosition = e.point;
waypointPopup?.hide();
e.preventDefault();
_map.dragPan.disable();
_map.on('touchmove', this.waypointLayerOnMouseMoveBinded);
_map.once('touchend', this.waypointLayerOnMouseUpBinded);
}
waypointLayerOnMouseMove(e: MapLayerMouseEvent | MapLayerTouchEvent) {
if (this.draggedWaypointIndex === null || e.point.equals(this.draggingStartingPosition)) {
return;
}
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, true);
(
this.currentWaypointData!.features[this.draggedWaypointIndex].geometry as GeoJSON.Point
).coordinates = [e.lngLat.lng, e.lngLat.lat];
let waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as
| GeoJSONSource
| undefined;
if (waypointSource) {
waypointSource.updateData({
update: [
{
id: this.draggedWaypointIndex,
newGeometry: {
type: 'Point',
coordinates: [e.lngLat.lng, e.lngLat.lat],
},
},
],
});
}
}
waypointLayerOnMouseUp(e: MapLayerMouseEvent | MapLayerTouchEvent) {
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
const _map = get(map);
if (!_map) {
return;
}
_map.dragPan.enable();
_map.off('mousemove', this.waypointLayerOnMouseMoveBinded);
_map.off('touchmove', this.waypointLayerOnMouseMoveBinded);
if (this.draggedWaypointIndex === null) {
return;
}
if (e.point.equals(this.draggingStartingPosition)) {
this.draggedWaypointIndex = null;
return;
}
getElevation([
{
lat: e.lngLat.lat,
lon: e.lngLat.lng,
},
]).then((ele) => {
if (this.draggedWaypointIndex === null) {
return;
}
fileActionManager.applyToFile(this.fileId, (file) => {
let wpt = file.wpt[this.draggedWaypointIndex!];
wpt.setCoordinates({
lat: e.lngLat.lat,
lon: e.lngLat.lng,
});
wpt.ele = ele[0];
});
this.draggedWaypointIndex = null;
});
}
getGeoJSON(): GeoJSON.FeatureCollection { getGeoJSON(): GeoJSON.FeatureCollection {
let file = get(this.file)?.file; let file = get(this.file)?.file;
if (!file) { if (!file) {
@@ -539,6 +739,7 @@ export class GPXLayer {
} }
feature.properties.trackIndex = trackIndex; feature.properties.trackIndex = trackIndex;
feature.properties.segmentIndex = segmentIndex; feature.properties.segmentIndex = segmentIndex;
feature.properties.trackSegmentId = `${trackIndex}-${segmentIndex}`;
segmentIndex++; segmentIndex++;
if (segmentIndex >= file.trk[trackIndex].trkseg.length) { if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
@@ -548,4 +749,52 @@ export class GPXLayer {
} }
return data; return data;
} }
getWaypointsGeoJSON(): GeoJSON.FeatureCollection {
let file = get(this.file)?.file;
let data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
};
if (!file) {
return data;
}
file.wpt.forEach((waypoint, index) => {
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [waypoint.getLongitude(), waypoint.getLatitude()],
},
properties: {
fileId: this.fileId,
waypointIndex: index,
icon: `waypoint-${getSymbolKey(waypoint.sym) ?? 'default'}-${this.layerColor}`,
},
});
});
return data;
}
loadIcons() {
const _map = get(map);
let file = get(this.file)?.file;
if (!_map || !file) {
return;
}
let symbols = new Set<string | undefined>();
file.wpt.forEach((waypoint) => {
symbols.add(getSymbolKey(waypoint.sym));
});
symbols.forEach((symbol) => {
const iconId = `waypoint-${symbol ?? 'default'}-${this.layerColor}`;
loadSVGIcon(_map, iconId, getSvgForSymbol(symbol, this.layerColor));
});
}
} }
@@ -1,4 +1,5 @@
import { GPXFileStateCollectionObserver } from '$lib/logic/file-state'; import { GPXFileStateCollectionObserver } from '$lib/logic/file-state';
import { writable } from 'svelte/store';
import { GPXLayer } from './gpx-layer'; import { GPXLayer } from './gpx-layer';
export class GPXLayerCollection { export class GPXLayerCollection {
@@ -42,3 +43,4 @@ export class GPXLayerCollection {
} }
export const gpxLayers = new GPXLayerCollection(); export const gpxLayers = new GPXLayerCollection();
export const gpxColors = writable(new Map<string, string>());
@@ -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');
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()); 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,26 +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(slicedGPXStatistics)?.[0] ?? get(gpxStatistics); const statistics = get(gpxStatistics);
const slicedStatistics = get(slicedGPXStatistics);
const hovered = get(hoveredPoint);
const hidden = get(allHidden); const hidden = get(allHidden);
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING && !hidden) { if (!hidden) {
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(map_); const data: GeoJSON.FeatureCollection = {
this.end type: 'FeatureCollection',
.setLngLat( features: [],
statistics.local.points[statistics.local.points.length - 1].getCoordinates() };
)
.addTo(map_); if (statistics.global.length > 0 && tool !== Tool.ROUTING) {
const start = statistics
.getTrackPoint(slicedStatistics?.[1] ?? 0)!
.trkpt.getCoordinates();
const end = statistics
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
.trkpt.getCoordinates();
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [start.lon, start.lat],
},
properties: {
icon: 'start-marker',
},
});
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [end.lon, end.lat],
},
properties: {
icon: 'end-marker',
},
});
}
if (hovered) {
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [hovered.lon, hovered.lat],
},
properties: {
icon: 'hover-marker',
},
});
}
let source = map_.getSource('start-end-markers') as GeoJSONSource | undefined;
if (source) {
source.setData(data);
} else { } 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 } 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,22 +168,13 @@
return $tree; return $tree;
}); });
if ( currentOverlays.update(($overlays) => {
$currentOverlays.overlays['custom'] && if (!$overlays.overlays.hasOwnProperty('custom')) {
$currentOverlays.overlays['custom'][layerId] && $overlays.overlays['custom'] = {};
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
} }
} $overlays.overlays['custom'][layerId] = true;
return $overlays;
if (!$currentOverlays.overlays.hasOwnProperty('custom')) { });
$currentOverlays.overlays['custom'] = {};
}
$currentOverlays.overlays['custom'][layerId] = true;
if (!$customOverlayOrder.includes(layerId)) { if (!$customOverlayOrder.includes(layerId)) {
$customOverlayOrder = [...$customOverlayOrder, layerId]; $customOverlayOrder = [...$customOverlayOrder, layerId];
@@ -216,49 +199,15 @@
$previousBasemap = defaultBasemap; $previousBasemap = defaultBasemap;
} }
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer( $selectedBasemapTree = remove($selectedBasemapTree, layerId);
$selectedBasemapTree.basemaps['custom'],
layerId
);
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
$selectedBasemapTree.basemaps = tryDeleteLayer(
$selectedBasemapTree.basemaps,
'custom'
);
}
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId); $customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
} else { } else {
$currentOverlays.overlays['custom'][layerId] = false; if ($currentOverlays) {
if ($previousOverlays.overlays['custom']) { $currentOverlays = remove($currentOverlays, layerId);
$previousOverlays.overlays['custom'] = tryDeleteLayer(
$previousOverlays.overlays['custom'],
layerId
);
}
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
$selectedOverlayTree.overlays['custom'],
layerId
);
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
$selectedOverlayTree.overlays = tryDeleteLayer(
$selectedOverlayTree.overlays,
'custom'
);
} }
$previousOverlays = remove($previousOverlays, layerId);
$selectedOverlayTree = remove($selectedOverlayTree, layerId);
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId); $customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
if (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
} }
$customLayers = tryDeleteLayer($customLayers, layerId); $customLayers = tryDeleteLayer($customLayers, layerId);
} }
@@ -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,127 +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', 'glyphs-and-sprite'].includes(imprt.id)
) {
acc[imprt.id] = imprt;
}
return acc;
},
{}
) || {};
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
toRemove.forEach((id) => {
$map?.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);
@@ -13,6 +13,7 @@
overlays, overlays,
overlayTree, overlayTree,
overpassTree, overpassTree,
terrainSources,
} from '$lib/assets/layers'; } from '$lib/assets/layers';
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils'; import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
@@ -20,6 +21,7 @@
import CustomLayers from './CustomLayers.svelte'; import CustomLayers from './CustomLayers.svelte';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
const { const {
selectedBasemapTree, selectedBasemapTree,
@@ -30,8 +32,11 @@
currentOverpassQueries, currentOverpassQueries,
customLayers, customLayers,
opacities, opacities,
terrainSource,
} = settings; } = settings;
const { isLayerFromExtension, getLayerName } = extensionAPI;
let { open = $bindable() }: { open: boolean } = $props(); let { open = $bindable() }: { open: boolean } = $props();
let accordionValue: string | undefined = $state(undefined); let accordionValue: string | undefined = $state(undefined);
@@ -51,7 +56,7 @@
} }
$effect(() => { $effect(() => {
if ($selectedBasemapTree && $currentBasemap) { if (open && $selectedBasemapTree && $currentBasemap) {
if (!isSelected($selectedBasemapTree, $currentBasemap)) { if (!isSelected($selectedBasemapTree, $currentBasemap)) {
if (!isSelected($selectedBasemapTree, defaultBasemap)) { if (!isSelected($selectedBasemapTree, defaultBasemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap); $selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
@@ -62,7 +67,7 @@
}); });
$effect(() => { $effect(() => {
if ($selectedOverlayTree) { if (open && $selectedOverlayTree) {
untrack(() => { untrack(() => {
if ($currentOverlays) { if ($currentOverlays) {
let overlayLayers = getLayers($currentOverlays); let overlayLayers = getLayers($currentOverlays);
@@ -83,7 +88,7 @@
}); });
$effect(() => { $effect(() => {
if ($selectedOverpassTree) { if (open && $selectedOverpassTree) {
untrack(() => { untrack(() => {
if ($currentOverpassQueries) { if ($currentOverpassQueries) {
let overlayLayers = getLayers($currentOverpassQueries); let overlayLayers = getLayers($currentOverpassQueries);
@@ -157,21 +162,29 @@
type="single" type="single"
onValueChange={setOpacityFromSelection} onValueChange={setOpacityFromSelection}
> >
<Select.Trigger class="h-8 mr-1 w-full"> <Select.Trigger class="mr-1 w-full" size="sm">
{#if selectedOverlay} {#if selectedOverlay}
{#if isSelected($selectedOverlayTree, selectedOverlay)} {#if isSelected($selectedOverlayTree, selectedOverlay)}
{i18n._(`layers.label.${selectedOverlay}`)} {#if $isLayerFromExtension(selectedOverlay)}
{$getLayerName(selectedOverlay)}
{:else if $customLayers.hasOwnProperty(selectedOverlay)} {:else if $customLayers.hasOwnProperty(selectedOverlay)}
{$customLayers[selectedOverlay].name} {$customLayers[selectedOverlay].name}
{:else}
{i18n._(`layers.label.${selectedOverlay}`)}
{/if}
{/if} {/if}
{/if} {/if}
</Select.Trigger> </Select.Trigger>
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto"> <Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
{#each Object.keys(overlays) as id} {#each Object.keys(overlays) as id}
{#if isSelected($selectedOverlayTree, id)} {#if isSelected($selectedOverlayTree, id)}
<Select.Item value={id} <Select.Item value={id}>
>{i18n._(`layers.label.${id}`)}</Select.Item {#if $isLayerFromExtension(id)}
> {$getLayerName(id)}
{:else}
{i18n._(`layers.label.${id}`)}
{/if}
</Select.Item>
{/if} {/if}
{/each} {/each}
{#each Object.entries($customLayers) as [id, layer]} {#each Object.entries($customLayers) as [id, layer]}
@@ -200,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
} }
@@ -222,6 +237,23 @@
</ScrollArea> </ScrollArea>
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
<Accordion.Item value="terrain-source">
<Accordion.Trigger>{i18n._('layers.terrain')}</Accordion.Trigger>
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
<Select.Root bind:value={$terrainSource} type="single">
<Select.Trigger class="mr-1 w-full" size="sm">
{i18n._(`layers.label.${$terrainSource}`)}
</Select.Trigger>
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
{#each Object.keys(terrainSources) as id}
<Select.Item value={id}>
{i18n._(`layers.label.${id}`)}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root> </Accordion.Root>
</ScrollArea> </ScrollArea>
</Sheet.Header> </Sheet.Header>
@@ -7,6 +7,7 @@
import { anySelectedLayer } from './utils'; import { anySelectedLayer } from './utils';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
let { let {
name, name,
@@ -25,6 +26,7 @@
} = $props(); } = $props();
const { customLayers } = settings; const { customLayers } = settings;
const { isLayerFromExtension, getLayerName } = extensionAPI;
$effect.pre(() => { $effect.pre(() => {
if (checked !== undefined) { if (checked !== undefined) {
@@ -72,6 +74,8 @@
<Label for="{name}-{id}" class="flex flex-row items-center gap-1"> <Label for="{name}-{id}" class="flex flex-row items-center gap-1">
{#if $customLayers.hasOwnProperty(id)} {#if $customLayers.hasOwnProperty(id)}
{$customLayers[id].name} {$customLayers[id].name}
{:else if $isLayerFromExtension(id)}
{$getLayerName(id)}
{:else} {:else}
{i18n._(`layers.label.${id}`)} {i18n._(`layers.label.${id}`)}
{/if} {/if}
@@ -81,7 +85,7 @@
{:else if anySelectedLayer(node[id])} {:else if anySelectedLayer(node[id])}
<CollapsibleTreeNode {id}> <CollapsibleTreeNode {id}>
{#snippet trigger()} {#snippet trigger()}
<span>{i18n._(`layers.label.${id}`)}</span> <span>{i18n._(`layers.label.${id}`, id)}</span>
{/snippet} {/snippet}
{#snippet content()} {#snippet content()}
<div class="ml-2"> <div class="ml-2">
@@ -9,18 +9,23 @@
import { fileActions } from '$lib/logic/file-actions'; import { fileActions } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection'; import { selection } from '$lib/logic/selection';
export let poi: PopupItem<any>; let {
poi,
}: {
poi: PopupItem<any>;
} = $props();
let tags: { [key: string]: string } = {}; let tags: Record<string, string> = $derived(poi ? JSON.parse(poi.item.tags) : {});
let name = ''; let name = $derived.by(() => {
$: if (poi) { if (poi) {
tags = JSON.parse(poi.item.tags);
if (tags.name !== undefined && tags.name !== '') { if (tags.name !== undefined && tags.name !== '') {
name = tags.name; return tags.name;
} else { } else {
name = i18n._(`layers.label.${poi.item.query}`); return i18n._(`layers.label.${poi.item.query}`);
} }
} }
return '';
});
function addToFile() { function addToFile() {
const desc = Object.entries(tags) const desc = Object.entries(tags)
@@ -49,32 +54,31 @@
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0"> <Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0">
<Card.Header class="p-0 gap-0"> <Card.Header class="p-0 gap-0">
<Card.Title class="text-md"> <Card.Title class="text-md flex flex-row">
<div class="flex flex-row gap-3">
<div class="flex flex-col"> <div class="flex flex-col">
{name} <p>{name}</p>
<div class="text-muted-foreground text-xs font-normal"> <div class="text-muted-foreground text-xs font-normal">
{poi.item.lat.toFixed(6)}&deg; {poi.item.lon.toFixed(6)}&deg; {poi.item.lat.toFixed(6)}&deg; {poi.item.lon.toFixed(6)}&deg;
</div> </div>
</div> </div>
<Button <Button
class="ml-auto" class="ml-auto"
variant="outline" variant="outline"
size="icon" size="icon-sm"
href="https://www.openstreetmap.org/edit?editor=id&{poi.item.type ?? href="https://www.openstreetmap.org/edit?editor=id&{poi.item.type ?? 'node'}={poi
'node'}={poi.item.id}" .item.id}"
target="_blank" target="_blank"
> >
<PencilLine size="16" /> <PencilLine size="16" />
</Button> </Button>
</div>
</Card.Title> </Card.Title>
</Card.Header> </Card.Header>
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all"> <Card.Content class="flex flex-col gap-1 p-0 text-sm whitespace-normal break-all">
<ScrollArea class="flex flex-col max-h-[30dvh]"> <ScrollArea class="flex flex-col max-h-[30dvh]">
{#if tags.image || tags['image:0']} {#if tags.image || tags['image:0']}
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto"> <div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
<!-- svelte-ignore a11y-missing-attribute --> <!-- svelte-ignore a11y_missing_attribute -->
<img src={tags.image ?? tags['image:0']} /> <img src={tags.image ?? tags['image:0']} />
</div> </div>
{/if} {/if}
@@ -95,8 +99,14 @@
{/each} {/each}
</div> </div>
</ScrollArea> </ScrollArea>
<Button class="mt-2" variant="outline" disabled={$selection.size === 0} onclick={addToFile}> <Button
<MapPin size="16" /> size="sm"
class="mt-1 justify-start"
variant="outline"
disabled={$selection.size === 0}
onclick={addToFile}
>
<MapPin size="14" />
{i18n._('toolbar.waypoint.add')} {i18n._('toolbar.waypoint.add')}
</Button> </Button>
</Card.Content> </Card.Content>
@@ -0,0 +1,213 @@
import { settings } from '$lib/logic/settings';
import { derived, get, writable, type Writable } from 'svelte/store';
import { isSelected, remove, removeAll } from './utils';
import { overlays, overlayTree } from '$lib/assets/layers';
import { browser } from '$app/environment';
import { map } from '$lib/components/map/map';
const { currentOverlays, previousOverlays, selectedOverlayTree } = settings;
export type CustomOverlay = {
extensionName: string;
id: string;
name: string;
tileUrls: string[];
maxZoom?: number;
};
export class ExtensionAPI {
private _overlays: Writable<Map<string, CustomOverlay>> = writable(new Map());
init() {
if (browser && !window.hasOwnProperty('gpxstudio')) {
Object.defineProperty(window, 'gpxstudio', {
value: this,
});
addEventListener('beforeunload', () => {
this.destroy();
});
}
}
ensureLoaded(): Promise<void> {
let unsubscribe: () => void;
const promise = new Promise<void>((resolve) => {
map.onLoad(() => {
unsubscribe = currentOverlays.subscribe((current) => {
if (current) {
resolve();
}
});
});
});
promise.finally(() => {
unsubscribe?.();
});
return promise;
}
addOrUpdateOverlay(overlay: CustomOverlay) {
if (
!overlay.extensionName ||
!overlay.id ||
!overlay.name ||
!overlay.tileUrls ||
overlay.tileUrls.length === 0
) {
throw new Error(
'Overlay must have an extensionName, id, name, and at least one tile URL.'
);
}
overlay.id = this.getOverlayId(overlay.id);
this._overlays.update(($overlays) => {
$overlays.set(overlay.id, overlay);
return $overlays;
});
overlays[overlay.id] = {
version: 8,
sources: {
[overlay.id]: {
type: 'raster',
tiles: overlay.tileUrls,
tileSize: overlay.tileUrls.some((url) => url.includes('512')) ? 512 : 256,
maxzoom: overlay.maxZoom ?? 22,
},
},
layers: [
{
id: overlay.id,
type: 'raster',
source: overlay.id,
},
],
};
if (!overlayTree.overlays.hasOwnProperty(overlay.extensionName)) {
overlayTree.overlays[overlay.extensionName] = {};
}
overlayTree.overlays[overlay.extensionName][overlay.id] = true;
selectedOverlayTree.update((selected) => {
if (!selected.overlays.hasOwnProperty(overlay.extensionName)) {
selected.overlays[overlay.extensionName] = {};
}
selected.overlays[overlay.extensionName][overlay.id] = true;
return selected;
});
const current = get(currentOverlays);
let show = false;
if (current && isSelected(current, overlay.id)) {
show = true;
try {
get(map)?.removeLayer(overlay.id);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
currentOverlays.update((current) => {
if (!current.overlays.hasOwnProperty(overlay.extensionName)) {
current.overlays[overlay.extensionName] = {};
}
current.overlays[overlay.extensionName][overlay.id] = show;
return current;
});
}
filterOverlays(ids: string[]) {
ids = ids.map((id) => this.getOverlayId(id));
const idsToRemove = Array.from(get(this._overlays).keys()).filter(
(id) => !ids.includes(id)
);
currentOverlays.update((current) => {
removeAll(current, idsToRemove);
return current;
});
previousOverlays.update((previous) => {
removeAll(previous, idsToRemove);
return previous;
});
selectedOverlayTree.update((selected) => {
removeAll(selected, idsToRemove);
return selected;
});
Object.keys(overlays).forEach((id) => {
if (idsToRemove.includes(id)) {
delete overlays[id];
}
});
removeAll(overlayTree, idsToRemove);
this._overlays.update(($overlays) => {
$overlays.forEach((_, id) => {
if (idsToRemove.includes(id)) {
$overlays.delete(id);
}
});
return $overlays;
});
}
updateOverlaysOrder(ids: string[]) {
ids = ids.map((id) => this.getOverlayId(id));
selectedOverlayTree.update((selected) => {
let isSelected: Record<string, boolean> = {};
ids.forEach((id) => {
const overlay = get(this._overlays).get(id);
if (
overlay &&
selected.overlays.hasOwnProperty(overlay.extensionName) &&
selected.overlays[overlay.extensionName].hasOwnProperty(id)
) {
isSelected[id] = selected.overlays[overlay.extensionName][id];
delete selected.overlays[overlay.extensionName][id];
}
});
Object.entries(isSelected).forEach(([id, value]) => {
const overlay = get(this._overlays).get(id)!;
selected.overlays[overlay.extensionName][id] = value;
});
return selected;
});
}
isLayerFromExtension = derived(this._overlays, ($overlays) => {
return (id: string) => $overlays.has(id);
});
getLayerName = derived(this._overlays, ($overlays) => {
return (id: string) => $overlays.get(id)?.name || '';
});
private getOverlayId(id: string): string {
return `extension-${id}`;
}
private destroy() {
const ids = Array.from(get(this._overlays).keys());
currentOverlays.update((current) => {
ids.forEach((id) => {
remove(current, id);
});
return current;
});
previousOverlays.update((previous) => {
ids.forEach((id) => {
remove(previous, id);
});
return previous;
});
selectedOverlayTree.update((selected) => {
ids.forEach((id) => {
remove(selected, id);
});
return selected;
});
}
}
export const extensionAPI = new ExtensionAPI();
@@ -6,6 +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 type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '../style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
const { currentOverpassQueries } = settings; const { currentOverpassQueries } = settings;
@@ -20,11 +24,12 @@ liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
}); });
export class OverpassLayer { export class OverpassLayer {
overpassUrl = 'https://overpass.private.coffee/api/interpreter'; overpassUrl = 'https://maps.mail.ru/osm/tools/overpass/api/interpreter';
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();
@@ -35,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,
@@ -47,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(() => {
@@ -71,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 {
@@ -85,7 +98,8 @@ export class OverpassLayer {
} }
if (!this.map.getLayer('overpass')) { if (!this.map.getLayer('overpass')) {
this.map.addLayer({ this.map.addLayer(
{
id: 'overpass', id: 'overpass',
type: 'symbol', type: 'symbol',
source: 'overpass', source: 'overpass',
@@ -95,13 +109,13 @@ export class OverpassLayer {
'icon-padding': 0, 'icon-padding': 0,
'icon-allow-overlap': ['step', ['zoom'], false, 14, true], 'icon-allow-overlap': ['step', ['zoom'], false, 14, true],
}, },
}); },
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()]);
} 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
} }
@@ -109,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 {
@@ -242,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>`
`); );
}
}); });
} }
} }
@@ -283,10 +288,12 @@ function getQuery(query: string) {
} }
} }
function getQueryItem(tags: Record<string, string | boolean | string[]>) { function getQueryItem(tags: Record<string, string | string[]>) {
let arrayEntry = Object.values(tags).find((value) => Array.isArray(value)); let arrayEntry = Object.entries(tags).find((entry): entry is [string, string[]] =>
Array.isArray(entry[1])
);
if (arrayEntry !== undefined) { if (arrayEntry !== undefined) {
return arrayEntry return arrayEntry[1]
.map( .map(
(val) => (val) =>
`nwr${Object.entries(tags) `nwr${Object.entries(tags)
@@ -309,7 +316,7 @@ function belongsToQuery(element: any, query: string) {
} }
} }
function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) { function belongsToQueryItem(element: any, tags: Record<string, string | string[]>) {
return Object.entries(tags).every(([tag, value]) => return Object.entries(tags).every(([tag, value]) =>
Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value
); );
@@ -55,4 +55,24 @@ export function toggle(node: LayerTreeType, id: string) {
return node; return node;
} }
export const customBasemapUpdate = writable(0); export function remove(node: LayerTreeType, id: string) {
Object.keys(node).forEach((key) => {
if (key === id) {
delete node[key];
} else if (typeof node[key] !== 'boolean') {
remove(node[key], id);
}
});
return node;
}
export function removeAll(node: LayerTreeType, ids: string[]) {
Object.keys(node).forEach((key) => {
if (ids.includes(key)) {
delete node[key];
} else if (typeof node[key] !== 'boolean') {
removeAll(node[key], ids);
}
});
return node;
}
@@ -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);
} }
} }
+83 -130
View File
@@ -1,100 +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 { ANCHOR_LAYER_KEY, StyleManager } from '$lib/components/map/style';
import { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings; const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings;
let fitBoundsOptions: mapboxgl.MapOptions['fitBoundsOptions'] = { let fitBoundsOptions: maplibregl.MapOptions['fitBoundsOptions'] = {
maxZoom: 15, maxZoom: 15,
linear: true, linear: true,
easing: () => 1, easing: () => 1,
}; };
export class MapboxGLMap { export class MapLibreGLMap {
private _map: Writable<mapboxgl.Map | null> = writable(null); private _maptilerKey: string = '';
private _onLoadCallbacks: ((map: mapboxgl.Map) => void)[] = []; private _map: maplibregl.Map | null = null;
private _mapStore: Writable<maplibregl.Map | null> = writable(null);
private _styleManager: StyleManager | null = null;
private _onLoadCallbacks: ((map: maplibregl.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( init(
accessToken: string, maptilerKey: string,
language: string, language: string,
hash: boolean, hash: boolean,
geocoder: boolean, geocoder: boolean,
geolocate: boolean geolocate: boolean
) { ) {
const map = new mapboxgl.Map({ 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: {
layers: [], type: 'globe',
imports: [
{
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
url: '',
data: {
version: 8,
sources: {},
layers: [],
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${accessToken}`,
}, },
},
{
id: 'basemap',
url: '',
},
{
id: 'overlays',
url: '',
data: {
version: 8,
sources: {}, sources: {},
layers: [], layers: [],
}, },
},
],
},
projection: 'globe',
zoom: 0, zoom: 0,
hash: hash, hash: hash,
language,
attributionControl: false,
logoPosition: 'bottom-right',
boxZoom: false, boxZoom: false,
maxPitch: 85,
}); });
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: {
@@ -104,74 +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.addSource('mapbox-dem', {
type: 'raster-dem',
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
tileSize: 512,
maxzoom: 14,
});
if (map.getPitch() > 0) {
map.setTerrain({
source: 'mapbox-dem',
exaggeration: 1,
});
}
map.setFog({
color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)',
'horizon-blend': 0.1,
'space-color': 'rgb(156, 240, 255)',
});
map.on('pitch', () => {
if (map.getPitch() > 0) {
map.setTerrain({
source: 'mapbox-dem',
exaggeration: 1,
});
} else {
map.setTerrain(null);
}
});
});
map.on('load', () => { 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();
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()));
@@ -184,44 +133,48 @@ export class MapboxGLMap {
); );
} }
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 });
}
} }
} }
} }
export const map = new MapboxGLMap(); onLoad(callback: (map: maplibregl.Map) => void) {
if (this._map) {
callback(this._map);
} else {
this._onLoadCallbacks.push(callback);
}
}
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 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,7 +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 '../style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
const mapillarySource: VectorSourceSpecification = { const mapillarySource: VectorSourceSpecification = {
type: 'vector', type: 'vector',
@@ -41,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;
@@ -52,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',
@@ -61,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,
}); });
@@ -99,20 +105,20 @@ export class MapillaryLayer {
this.map.addSource('mapillary', mapillarySource); this.map.addSource('mapillary', mapillarySource);
} }
if (!this.map.getLayer('mapillary-sequence')) { if (!this.map.getLayer('mapillary-sequence')) {
this.map.addLayer(mapillarySequenceLayer); this.map.addLayer(mapillarySequenceLayer, ANCHOR_LAYER_KEY.mapillary);
} }
if (!this.map.getLayer('mapillary-image')) { if (!this.map.getLayer('mapillary-image')) {
this.map.addLayer(mapillaryImageLayer); 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');
@@ -134,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 &&
+231
View File
@@ -0,0 +1,231 @@
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);
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() {
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,
}); });
@@ -16,10 +16,11 @@
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { Trash2 } from '@lucide/svelte'; import { Trash2 } from '@lucide/svelte';
import { 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) {
@@ -63,7 +64,8 @@
}); });
} }
if (!$map.getLayer('rectangle')) { if (!$map.getLayer('rectangle')) {
$map.addLayer({ $map.addLayer(
{
id: 'rectangle', id: 'rectangle',
type: 'fill', type: 'fill',
source: 'rectangle', source: 'rectangle',
@@ -71,7 +73,9 @@
'fill-color': 'SteelBlue', 'fill-color': 'SteelBlue',
'fill-opacity': 0.5, 'fill-opacity': 0.5,
}, },
}); },
ANCHOR_LAYER_KEY.interactions
);
} }
} }
} }
@@ -2,7 +2,6 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { MountainSnow } from '@lucide/svelte'; import { MountainSnow } from '@lucide/svelte';
import { map } from '$lib/components/map/map';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection'; import { selection } from '$lib/logic/selection';
@@ -20,11 +19,7 @@
variant="outline" variant="outline"
class="whitespace-normal h-fit" class="whitespace-normal h-fit"
disabled={!validSelection} disabled={!validSelection}
onclick={() => { onclick={() => fileActions.addElevationToSelection()}
if ($map) {
fileActions.addElevationToSelection($map);
}
}}
> >
<MountainSnow size="16" class="shrink-0" /> <MountainSnow size="16" class="shrink-0" />
{i18n._('toolbar.elevation.button')} {i18n._('toolbar.elevation.button')}
@@ -38,7 +38,7 @@
let endTime: string | undefined = $state(undefined); let endTime: string | undefined = $state(undefined);
let movingTime: number | undefined = $state(undefined); let movingTime: number | undefined = $state(undefined);
let speed: number | undefined = $state(undefined); let speed: number | undefined = $state(undefined);
let artificial = $state(false); let artificial = $state(true);
function toCalendarDate(date: Date): CalendarDate { function toCalendarDate(date: Date): CalendarDate {
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate()); return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
@@ -346,7 +346,7 @@
let fileId = item.getFileId(); let fileId = item.getFileId();
fileActionManager.applyToFile(fileId, (file) => { fileActionManager.applyToFile(fileId, (file) => {
if (item instanceof ListFileItem) { if (item instanceof ListFileItem) {
if (artificial || !$gpxStatistics.global.time.moving) { if (artificial && !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps( file.createArtificialTimestamps(
getDate(startDate!, startTime!), getDate(startDate!, startTime!),
movingTime! movingTime!
@@ -359,7 +359,7 @@
); );
} }
} else if (item instanceof ListTrackItem) { } else if (item instanceof ListTrackItem) {
if (artificial || !$gpxStatistics.global.time.moving) { if (artificial && !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps( file.createArtificialTimestamps(
getDate(startDate!, startTime!), getDate(startDate!, startTime!),
movingTime!, movingTime!,
@@ -374,7 +374,7 @@
); );
} }
} else if (item instanceof ListTrackSegmentItem) { } else if (item instanceof ListTrackSegmentItem) {
if (artificial || !$gpxStatistics.global.time.moving) { if (artificial && !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps( file.createArtificialTimestamps(
getDate(startDate!, startTime!), getDate(startDate!, startTime!),
movingTime!, movingTime!,
@@ -10,7 +10,7 @@
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection'; import { selection } from '$lib/logic/selection';
import { minTolerance, ReducedGPXLayerCollection, tolerance } from './reduce.svelte'; import { minTolerance, ReducedGPXLayerCollection, tolerance } from './utils.svelte';
let props: { class?: string } = $props(); let props: { class?: string } = $props();
@@ -1,11 +1,12 @@
import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list'; import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list';
import { 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, type Writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
export const minTolerance = 0.1; export const minTolerance = 0.1;
@@ -28,17 +29,15 @@ export class ReducedGPXLayer {
update() { update() {
const file = this._fileState.file; const file = this._fileState.file;
const stats = this._fileState.statistics; if (!file) {
if (!file || !stats) {
return; return;
} }
file.forEachSegment((segment, trackIndex, segmentIndex) => { file.forEachSegment((segment, trackIndex, segmentIndex) => {
let segmentItem = new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex); let segmentItem = new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex);
let statistics = stats.getStatisticsFor(segmentItem);
this._updateSimplified(segmentItem.getFullId(), [ this._updateSimplified(segmentItem.getFullId(), [
segmentItem, segmentItem,
statistics.local.points.length, segment.trkpt.length,
ramerDouglasPeucker(statistics.local.points, minTolerance), ramerDouglasPeucker(segment.trkpt, minTolerance),
]); ]);
}); });
} }
@@ -146,7 +145,8 @@ export class ReducedGPXLayerCollection {
}); });
} }
if (!map_.getLayer('simplified')) { if (!map_.getLayer('simplified')) {
map_.addLayer({ map_.addLayer(
{
id: 'simplified', id: 'simplified',
type: 'line', type: 'line',
source: 'simplified', source: 'simplified',
@@ -154,9 +154,9 @@ export class ReducedGPXLayerCollection {
'line-color': 'white', 'line-color': 'white',
'line-width': 3, 'line-width': 3,
}, },
}); },
} else { ANCHOR_LAYER_KEY.interactions
map_.moveLayer('simplified'); );
} }
} }
@@ -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();
@@ -163,11 +163,11 @@
{i18n._('toolbar.routing.activity')} {i18n._('toolbar.routing.activity')}
</span> </span>
<Select.Root type="single" bind:value={$routingProfile}> <Select.Root type="single" bind:value={$routingProfile}>
<Select.Trigger class="h-8 grow"> <Select.Trigger class="grow" size="sm">
{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}`
@@ -195,7 +195,7 @@
disabled={!validSelection} disabled={!validSelection}
onclick={fileActions.reverseSelection} onclick={fileActions.reverseSelection}
> >
<ArrowRightLeft size="12" />{i18n._('toolbar.routing.reverse.button')} <ArrowRightLeft class="size-3" />{i18n._('toolbar.routing.reverse.button')}
</ButtonWithTooltip> </ButtonWithTooltip>
<ButtonWithTooltip <ButtonWithTooltip
label={i18n._('toolbar.routing.route_back_to_start.tooltip')} label={i18n._('toolbar.routing.route_back_to_start.tooltip')}
@@ -231,7 +231,7 @@
} }
}} }}
> >
<House size="12" />{i18n._('toolbar.routing.route_back_to_start.button')} <House class="size-3" />{i18n._('toolbar.routing.route_back_to_start.button')}
</ButtonWithTooltip> </ButtonWithTooltip>
<ButtonWithTooltip <ButtonWithTooltip
label={i18n._('toolbar.routing.round_trip.tooltip')} label={i18n._('toolbar.routing.round_trip.tooltip')}
@@ -240,7 +240,7 @@
disabled={!validSelection} disabled={!validSelection}
onclick={fileActions.createRoundTripForSelection} onclick={fileActions.createRoundTripForSelection}
> >
<Repeat size="12" />{i18n._('toolbar.routing.round_trip.button')} <Repeat class="size-3" />{i18n._('toolbar.routing.round_trip.button')}
</ButtonWithTooltip> </ButtonWithTooltip>
</div> </div>
<div class="w-full flex flex-row gap-2 items-end justify-between"> <div class="w-full flex flex-row gap-2 items-end justify-between">
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,7 @@ import { get } from 'svelte/store';
const { routing, routingProfile, privateRoads } = settings; const { routing, routingProfile, privateRoads } = settings;
export const brouterProfiles: { [key: string]: string } = { export const routingProfiles: { [key: string]: string } = {
bike: 'Trekking-dry', bike: 'Trekking-dry',
racing_bike: 'fastbike', racing_bike: 'fastbike',
gravel_bike: 'gravel', gravel_bike: 'gravel',
@@ -19,7 +19,7 @@ export const brouterProfiles: { [key: string]: string } = {
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)); return getRoute(points, routingProfiles[get(routingProfile)], get(privateRoads));
} else { } else {
return getIntermediatePoints(points); return getIntermediatePoints(points);
} }
@@ -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) {
@@ -26,26 +26,24 @@
let validSelection = $derived( let validSelection = $derived(
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
$gpxStatistics.local.points.length > 0 $gpxStatistics.global.length > 0
); );
let maxSliderValue = $derived( let maxSliderValue = $derived(
validSelection && $gpxStatistics.local.points.length > 0 validSelection && $gpxStatistics.global.length > 0 ? $gpxStatistics.global.length - 1 : 1
? $gpxStatistics.local.points.length - 1
: 1
); );
let sliderValues = $derived([0, maxSliderValue]); let sliderValues = $derived([0, maxSliderValue]);
let canCrop = $derived(sliderValues[0] != 0 || sliderValues[1] != maxSliderValue); let canCrop = $derived(sliderValues[0] != 0 || sliderValues[1] != maxSliderValue);
onMount(() => { onMount(() => {
if ($map) { if ($map) {
splitControls = new SplitControls($map); splitControls = new SplitControls($map, map.layerEventManager!);
} }
}); });
function updateSlicedGPXStatistics() { function updateSlicedGPXStatistics() {
if (validSelection && canCrop) { if (validSelection && canCrop) {
$slicedGPXStatistics = [ $slicedGPXStatistics = [
get(gpxStatistics).slice(sliderValues[0], sliderValues[1]), get(gpxStatistics).sliced(sliderValues[0], sliderValues[1]),
sliderValues[0], sliderValues[0],
sliderValues[1], sliderValues[1],
]; ];
@@ -107,7 +105,7 @@
{i18n._('toolbar.scissors.split_as')} {i18n._('toolbar.scissors.split_as')}
</span> </span>
<Select.Root bind:value={$splitAs} type="single"> <Select.Root bind:value={$splitAs} type="single">
<Select.Trigger class="h-8 w-fit grow"> <Select.Trigger class="w-fit grow" size="sm">
{i18n._('gpx.' + $splitAs)} {i18n._('gpx.' + $splitAs)}
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
@@ -1,5 +1,3 @@
import { TrackPoint, TrackSegment } from 'gpx';
import mapboxgl from 'mapbox-gl';
import { ListTrackSegmentItem } from '$lib/components/file-list/file-list'; import { ListTrackSegmentItem } from '$lib/components/file-list/file-list';
import { currentTool, Tool } from '$lib/components/toolbar/tools'; import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors'; import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
@@ -9,19 +7,34 @@ import { gpxStatistics } from '$lib/logic/statistics';
import { get } from 'svelte/store'; 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 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 {
active: boolean = false; map: maplibregl.Map;
map: mapboxgl.Map; layerEventManager: MapLayerEventManager;
controls: ControlWithMarker[] = [];
shownControls: ControlWithMarker[] = [];
unsubscribes: Function[] = []; unsubscribes: Function[] = [];
toggleControlsForZoomLevelAndBoundsBinded: () => void = layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
this.toggleControlsForZoomLevelAndBounds.bind(this); layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.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;
loadSVGIcon(
this.map,
'split-control',
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="white" />
<g transform="translate(8 8)">
${Scissors.replace('stroke="currentColor"', 'stroke="black"')}
</g>
</svg>`
);
this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this))); this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this))); this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
@@ -31,29 +44,18 @@ export class SplitControls {
addIfNeeded() { addIfNeeded() {
let scissors = get(currentTool) === Tool.SCISSORS; let scissors = get(currentTool) === Tool.SCISSORS;
if (!scissors) { if (!scissors) {
if (this.active) {
this.remove(); this.remove();
}
return; return;
} }
if (this.active) {
this.updateControls(); this.updateControls();
} else {
this.add();
}
}
add() {
this.active = true;
this.map.on('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
this.map.on('move', this.toggleControlsForZoomLevelAndBoundsBinded);
} }
updateControls() { updateControls() {
// Update the markers when the files change let data: GeoJSON.FeatureCollection = {
let controlIndex = 0; type: 'FeatureCollection',
features: [],
};
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => { selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = fileStateCollection.getFile(fileId); let file = fileStateCollection.getFile(fileId);
@@ -64,30 +66,23 @@ export class SplitControls {
new ListTrackSegmentItem(fileId, trackIndex, segmentIndex) new ListTrackSegmentItem(fileId, trackIndex, segmentIndex)
) )
) { ) {
for (let point of segment.trkpt.slice(1, -1)) { for (let i = 1; i < segment.trkpt.length - 1; i++) {
// Update the existing controls (could be improved by matching the existing controls with the new ones?) let point = segment.trkpt[i];
if (point._data.anchor) { if (point._data.anchor) {
if (controlIndex < this.controls.length) { data.features.push({
this.controls[controlIndex].fileId = fileId; type: 'Feature',
this.controls[controlIndex].point = point; geometry: {
this.controls[controlIndex].segment = segment; type: 'Point',
this.controls[controlIndex].trackIndex = trackIndex; coordinates: [point.getLongitude(), point.getLatitude()],
this.controls[controlIndex].segmentIndex = segmentIndex; },
this.controls[controlIndex].marker.setLngLat( properties: {
point.getCoordinates() fileId: fileId,
); trackIndex: trackIndex,
} else { segmentIndex: segmentIndex,
this.controls.push( pointIndex: i,
this.createControl( minZoom: point._data.zoom,
point, },
segment, });
fileId,
trackIndex,
segmentIndex
)
);
}
controlIndex++;
} }
} }
} }
@@ -95,86 +90,86 @@ export class SplitControls {
} }
}, false); }, false);
while (controlIndex < this.controls.length) { try {
// Remove the extra controls let source = this.map.getSource('split-controls') as GeoJSONSource | undefined;
this.controls.pop()?.marker.remove(); if (source) {
source.setData(data);
} else {
this.map.addSource('split-controls', {
type: 'geojson',
data: data,
});
} }
this.toggleControlsForZoomLevelAndBounds(); if (!this.map.getLayer('split-controls')) {
this.map.addLayer(
{
id: 'split-controls',
type: 'symbol',
source: 'split-controls',
layout: {
'icon-image': 'split-control',
'icon-size': 0.25,
'icon-padding': 0,
},
filter: ['<=', ['get', 'minZoom'], ['zoom']],
},
ANCHOR_LAYER_KEY.interactions
);
this.layerEventManager.on(
'mouseenter',
'split-controls',
this.layerOnMouseEnterBinded
);
this.layerEventManager.on(
'mouseleave',
'split-controls',
this.layerOnMouseLeaveBinded
);
this.layerEventManager.on('click', 'split-controls', this.layerOnClickBinded);
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
} }
remove() { remove() {
this.active = false; this.layerEventManager.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
this.layerEventManager.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
this.layerEventManager.off('click', 'split-controls', this.layerOnClickBinded);
for (let control of this.controls) { try {
control.marker.remove(); if (this.map.getLayer('split-controls')) {
} this.map.removeLayer('split-controls');
this.map.off('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
this.map.off('move', this.toggleControlsForZoomLevelAndBoundsBinded);
} }
toggleControlsForZoomLevelAndBounds() { if (this.map.getSource('split-controls')) {
// Show markers only if they are in the current zoom level and bounds this.map.removeSource('split-controls');
this.shownControls.splice(0, this.shownControls.length); }
} catch (e) {
let southWest = this.map.unproject([0, this.map.getCanvas().height]); // No reliable way to check if the map is ready to remove sources and layers
let northEast = this.map.unproject([this.map.getCanvas().width, 0]);
let bounds = new mapboxgl.LngLatBounds(southWest, northEast);
let zoom = this.map.getZoom();
this.controls.forEach((control) => {
control.inZoom = control.point._data.zoom <= zoom;
if (control.inZoom && bounds.contains(control.marker.getLngLat())) {
control.marker.addTo(this.map);
this.shownControls.push(control);
} else {
control.marker.remove();
} }
});
} }
createControl( layerOnMouseEnter(e: any) {
point: TrackPoint, mapCursor.notify(MapCursorState.SPLIT_CONTROL, true);
segment: TrackSegment, }
fileId: string,
trackIndex: number,
segmentIndex: number
): ControlWithMarker {
let element = document.createElement('div');
element.className = `h-6 w-6 p-0.5 rounded-full bg-white border-2 border-black cursor-pointer`;
element.innerHTML = Scissors.replace('width="24"', '')
.replace('height="24"', '')
.replace('stroke="currentColor"', 'stroke="black"');
let marker = new mapboxgl.Marker({ layerOnMouseLeave() {
draggable: true, mapCursor.notify(MapCursorState.SPLIT_CONTROL, false);
className: 'z-10', }
element,
}).setLngLat(point.getCoordinates());
let control = { layerOnClick(e: maplibregl.MapLayerMouseEvent) {
point, let coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates;
segment,
fileId,
trackIndex,
segmentIndex,
marker,
inZoom: false,
};
marker.getElement().addEventListener('click', (e) => {
e.stopPropagation();
fileActions.split( fileActions.split(
get(splitAs), get(splitAs),
control.fileId, e.features![0].properties!.fileId,
control.trackIndex, e.features![0].properties!.trackIndex,
control.segmentIndex, e.features![0].properties!.segmentIndex,
control.point.getCoordinates(), { lon: coordinates[0], lat: coordinates[1] },
control.point._data.index e.features![0].properties!.pointIndex
); );
});
return control;
} }
destroy() { destroy() {
@@ -182,16 +177,3 @@ export class SplitControls {
this.unsubscribes.forEach((unsubscribe) => unsubscribe()); this.unsubscribes.forEach((unsubscribe) => unsubscribe());
} }
} }
type Control = {
segment: TrackSegment;
fileId: string;
trackIndex: number;
segmentIndex: number;
point: TrackPoint;
};
type ControlWithMarker = Control & {
marker: mapboxgl.Marker;
inZoom: boolean;
};
@@ -16,6 +16,8 @@
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 maplibregl from 'maplibre-gl';
import { getSvgForSymbol } from '$lib/components/map/gpx-layer/gpx-layer';
let props: { let props: {
class?: string; class?: string;
@@ -39,6 +41,21 @@
}) })
); );
let marker: maplibregl.Marker | null = null;
function reset() {
if ($selectedWaypoint) {
selectedWaypoint.reset();
} else {
name = '';
description = '';
link = '';
sym = '';
longitude = 0;
latitude = 0;
}
}
$effect(() => { $effect(() => {
if ($selectedWaypoint) { if ($selectedWaypoint) {
const wpt = $selectedWaypoint[0]; const wpt = $selectedWaypoint[0];
@@ -54,14 +71,7 @@
latitude = parseFloat(wpt.getLatitude().toFixed(6)); latitude = parseFloat(wpt.getLatitude().toFixed(6));
}); });
} else { } else {
untrack(() => { untrack(reset);
name = '';
description = '';
link = '';
sym = '';
longitude = 0;
latitude = 0;
});
} }
}); });
@@ -85,14 +95,14 @@
desc: description.length > 0 ? description : undefined, desc: description.length > 0 ? description : undefined,
cmt: description.length > 0 ? description : undefined, cmt: description.length > 0 ? description : undefined,
link: link.length > 0 ? { attributes: { href: link } } : undefined, link: link.length > 0 ? { attributes: { href: link } } : undefined,
sym: sym, sym: sym.length > 0 ? sym : undefined,
}, },
selectedWaypoint.wpt && selectedWaypoint.fileId selectedWaypoint.wpt && selectedWaypoint.fileId
? new ListWaypointItem(selectedWaypoint.fileId, selectedWaypoint.wpt._data.index) ? new ListWaypointItem(selectedWaypoint.fileId, selectedWaypoint.wpt._data.index)
: undefined : undefined
); );
selectedWaypoint.reset(); reset();
} }
function setCoordinates(e: any) { function setCoordinates(e: any) {
@@ -100,6 +110,37 @@
longitude = e.lngLat.lng.toFixed(6); longitude = e.lngLat.lng.toFixed(6);
} }
$effect(() => {
if ($selectedWaypoint) {
if (marker) {
marker.remove();
marker = null;
}
} else if (latitude != 0 || longitude != 0) {
if ($map) {
if (marker) {
marker.setLngLat([longitude, latitude]).getElement().innerHTML =
getSvgForSymbol(symbolKey);
} else {
let element = document.createElement('div');
element.classList.add('w-8', 'h-8');
element.innerHTML = getSvgForSymbol(symbolKey);
marker = new maplibregl.Marker({
element,
anchor: 'bottom',
})
.setLngLat([longitude, latitude])
.addTo($map);
}
}
} else {
if (marker) {
marker.remove();
marker = null;
}
}
});
onMount(() => { onMount(() => {
if ($map) { if ($map) {
$map.on('click', setCoordinates); $map.on('click', setCoordinates);
@@ -112,6 +153,10 @@
$map.off('click', setCoordinates); $map.off('click', setCoordinates);
mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, false); mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, false);
} }
if (marker) {
marker.remove();
marker = null;
}
}); });
</script> </script>
@@ -129,19 +174,27 @@
bind:value={description} bind:value={description}
id="description" id="description"
disabled={!canCreate && !$selectedWaypoint} disabled={!canCreate && !$selectedWaypoint}
class="min-h-8 h-8 py-1 px-3 text-sm"
/> />
<Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label> <Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label>
<Select.Root bind:value={sym} type="single"> <Select.Root bind:value={sym} type="single">
<Select.Trigger <Select.Trigger
id="symbol" id="symbol"
class="w-full h-8" size="sm"
class="w-full"
disabled={!canCreate && !$selectedWaypoint} disabled={!canCreate && !$selectedWaypoint}
> >
<span class="flex flex-row gap-1.5 items-center">
{#if symbolKey} {#if symbolKey}
{#if symbols[symbolKey].icon}
{@const Component = symbols[symbolKey].icon}
<Component size="14" />
{/if}
{i18n._(`gpx.symbol.${symbolKey}`)} {i18n._(`gpx.symbol.${symbolKey}`)}
{:else} {:else}
{sym} {sym}
{/if} {/if}
</span>
</Select.Trigger> </Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll"> <Select.Content class="max-h-60 overflow-y-scroll">
{#each sortedSymbols as [key, symbol]} {#each sortedSymbols as [key, symbol]}
@@ -149,7 +202,7 @@
<span> <span>
{#if symbol.icon} {#if symbol.icon}
{@const Component = symbol.icon} {@const Component = symbol.icon}
<Component size="14" class="inline-block align-sub mr-0.5" /> <Component size="14" class="inline-block align-sub" />
{:else} {:else}
<span class="w-4 inline-block"></span> <span class="w-4 inline-block"></span>
{/if} {/if}
@@ -210,7 +263,7 @@
{i18n._('toolbar.waypoint.create')} {i18n._('toolbar.waypoint.create')}
{/if} {/if}
</Button> </Button>
<Button variant="outline" size="icon" onclick={() => selectedWaypoint.reset()}> <Button variant="outline" size="icon" onclick={reset}>
<CircleX size="16" /> <CircleX size="16" />
</Button> </Button>
</div> </div>
+1 -1
View File
@@ -2,7 +2,7 @@
import { HeartHandshake } from '@lucide/svelte'; import { HeartHandshake } from '@lucide/svelte';
</script> </script>
## <HeartHandshake size="18" class="mr-1 inline-block align-baseline" /> Help keep the website free (and ad-free) ## <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. 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. 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.
+1 -1
View File
@@ -2,7 +2,7 @@
import { Languages } from '@lucide/svelte'; import { Languages } from '@lucide/svelte';
</script> </script>
## <Languages size="18" class="mr-1 inline-block align-baseline" /> Translation ## <Languages size="18" class="inline-block align-baseline" /> Translation
The website is translated by volunteers using a collaborative translation platform. 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>. You can contribute by adding or improving translations on our <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin project</a>.
+4 -4
View File
@@ -29,13 +29,13 @@ You can also drag and drop files directly from your file system into the window.
Create a copy of the currently selected files. Create a copy of the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Close ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
Close the currently selected files. Delete the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Close all ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
Close all files. Delete all files.
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Export... ### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Export...
+2 -2
View File
@@ -3,7 +3,7 @@ title: Route planning and editing
--- ---
<script> <script>
import { Pencil, Route, Bike, TriangleAlert, ArrowRightLeft, Home, Repeat, Trash2, CirclePlay, SquareArrowUpLeft } from '@lucide/svelte'; import { Pencil, Route, Bike, TriangleAlert, ArrowRightLeft, House, Repeat, Trash2, CirclePlay, SquareArrowUpLeft } from '@lucide/svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte'; import DocsNote from '$lib/components/docs/DocsNote.svelte';
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte'; import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
import DocsImage from '$lib/components/docs/DocsImage.svelte'; import DocsImage from '$lib/components/docs/DocsImage.svelte';
@@ -71,7 +71,7 @@ The following tools automate some common route modification operations.
Reverse the direction of the route. Reverse the direction of the route.
### <Home size="16" class="inline-block" style="margin-bottom: 2px" /> Back to start ### <House size="16" class="inline-block" style="margin-bottom: 2px" /> Back to start
Connect the last point of the route with the starting point, using the chosen routing settings. Connect the last point of the route with the starting point, using the chosen routing settings.
+1 -1
View File
@@ -2,7 +2,7 @@
import { HeartHandshake } from '@lucide/svelte'; import { HeartHandshake } from '@lucide/svelte';
</script> </script>
## <HeartHandshake size="18" class="mr-1 inline-block align-baseline" /> Ajuda a mantenir aquesta pàgina web gratuïta (i sense anuncis) ## <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. Cada cop que afegeixes o mous un punt GPS, els nostres servidors calculen la millor ruta possible.
També utilitzen l'API de <a href="https://mapbox.com" target="_blank">Mapbox</a> per ensenyar mapes bonics, donar informació sobre l'altitud i permetre la cerca de llocs d'interès. També utilitzen l'API de <a href="https://mapbox.com" target="_blank">Mapbox</a> per ensenyar mapes bonics, donar informació sobre l'altitud i permetre la cerca de llocs d'interès.
+1 -1
View File
@@ -2,7 +2,7 @@
import { Languages } from '@lucide/svelte'; import { Languages } from '@lucide/svelte';
</script> </script>
## <Languages size="18" class="mr-1 inline-block align-baseline" />Traducció ## <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. 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>. 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>.
+4 -4
View File
@@ -29,13 +29,13 @@ Pots arrossegar y deixar arxius directament des del seu sistema d'arxius cap a l
Crear una còpia dels arxius seleccionats. Crear una còpia dels arxius seleccionats.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Tanca ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
Tanca els arxius seleccionats. Delete the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Tanca tot ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
Tanca tots els arxius. Delete all files.
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Exportar... ### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Exportar...
+2 -2
View File
@@ -3,7 +3,7 @@ title: Planificació i edició de rutes
--- ---
<script> <script>
import { Pencil, Route, Bike, TriangleAlert, ArrowRightLeft, Home, Repeat, Trash2, CirclePlay, SquareArrowUpLeft } from '@lucide/svelte'; import { Pencil, Route, Bike, TriangleAlert, ArrowRightLeft, House, Repeat, Trash2, CirclePlay, SquareArrowUpLeft } from '@lucide/svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte'; import DocsNote from '$lib/components/docs/DocsNote.svelte';
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte'; import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
import DocsImage from '$lib/components/docs/DocsImage.svelte'; import DocsImage from '$lib/components/docs/DocsImage.svelte';
@@ -71,7 +71,7 @@ Les eines següents automatitzen algunes operacions comunes de modificació de r
Inverteix el sentit de la ruta. Inverteix el sentit de la ruta.
### <Home size="16" class="inline-block" style="margin-bottom: 2px" /> Tornar a l'inici ### <House size="16" class="inline-block" style="margin-bottom: 2px" /> Tornar a l'inici
Connecta l'últim punt de la ruta amb el punt d'inici, utilitzant la configuració de ruta escollida. Connecta l'últim punt de la ruta amb el punt d'inici, utilitzant la configuració de ruta escollida.
+1 -1
View File
@@ -2,7 +2,7 @@
import { HeartHandshake } from '@lucide/svelte'; import { HeartHandshake } from '@lucide/svelte';
</script> </script>
## <HeartHandshake size="18" class="mr-1 inline-block align-baseline" /> Pomozte udržet web zdarma (a bez reklam) ## <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. Vždy, když přidáte nebo přesunete GPS body, naše servery vypočítají nejlepší cestu po silniční síti.
Používáme také API z <a href="https://mapbox.com" target="_blank">Mapboxu</a> pro zobrazení krásných map, získání dat o nadmořské výšce a vyhledávání míst. Používáme také API z <a href="https://mapbox.com" target="_blank">Mapboxu</a> pro zobrazení krásných map, získání dat o nadmořské výšce a vyhledávání míst.
+1 -1
View File
@@ -2,7 +2,7 @@
import { Languages } from '@lucide/svelte'; import { Languages } from '@lucide/svelte';
</script> </script>
## <Languages size="18" class="mr-1 inline-block align-baseline" /> Překlad ## <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. 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>. 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>.
+4 -4
View File
@@ -29,13 +29,13 @@ Soubory můžete také přetáhnout přímo ze souborového systému do okna.
Vytvořit kopii aktuálně vybraných souborů. Vytvořit kopii aktuálně vybraných souborů.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Zavřít ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Smazat
Zavřít aktuálně vybrané soubory. Smazat aktuálně vybrané soubory.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Zavřít vše ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Smazat vše
Zavřít všechny soubory. Smazat všechny soubory.
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Export... ### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Export...
+3 -3
View File
@@ -3,7 +3,7 @@ title: Plánování a úpravy tras
--- ---
<script> <script>
import { Pencil, Route, Bike, TriangleAlert, ArrowRightLeft, Home, Repeat, Trash2, CirclePlay, SquareArrowUpLeft } from '@lucide/svelte'; import { Pencil, Route, Bike, TriangleAlert, ArrowRightLeft, House, Repeat, Trash2, CirclePlay, SquareArrowUpLeft } from '@lucide/svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte'; import DocsNote from '$lib/components/docs/DocsNote.svelte';
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte'; import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
import DocsImage from '$lib/components/docs/DocsImage.svelte'; import DocsImage from '$lib/components/docs/DocsImage.svelte';
@@ -24,7 +24,7 @@ Dialog lze minimalizovat kliknutím na <button><SquareArrowUpLeft size="16" clas
### <Route size="16" class="inline-block" style="margin-bottom: 2px" /> Plánování tras ### <Route size="16" class="inline-block" style="margin-bottom: 2px" /> Plánování tras
Když je plánování trasy aktivní, kotevní body umístěné nebo přesunuté po mapě budou projenou trasou vypočítanou v silniční síti <a href="https://www.openstreetmap.org" target="_blank">OpenStreetMap</a>. Když je plánování trasy aktivní, kotevní body umístěné nebo přesunuté po mapě budou propojeny trasou vypočítanou v silniční síti <a href="https://www.openstreetmap.org" target="_blank">OpenStreetMap</a>.
Zakázat plánování trasy pro propojení kotevních bodů přímými čarami. Zakázat plánování trasy pro propojení kotevních bodů přímými čarami.
Toto nastavení lze také přepnout stisknutím <kbd>F5</kbd>. Toto nastavení lze také přepnout stisknutím <kbd>F5</kbd>.
@@ -71,7 +71,7 @@ Následující nástroje automatizují některé běžné operace úpravy trasy.
Obrátit směr trasy. Obrátit směr trasy.
### <Home size="16" class="inline-block" style="margin-bottom: 2px" /> Zpět na začátek ### <House size="16" class="inline-block" style="margin-bottom: 2px" /> Zpět na začátek
Propojit poslední bod trasy s výchozím bodem pomocí zvoleného nastavení směrování. Propojit poslední bod trasy s výchozím bodem pomocí zvoleného nastavení směrování.
+1 -1
View File
@@ -2,7 +2,7 @@
import { HeartHandshake } from '@lucide/svelte'; import { HeartHandshake } from '@lucide/svelte';
</script> </script>
## <HeartHandshake size="18" class="mr-1 inline-block align-baseline" /> Help keep the website free (and ad-free) ## <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. 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. 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.
+1 -1
View File
@@ -2,7 +2,7 @@
import { Languages } from '@lucide/svelte'; import { Languages } from '@lucide/svelte';
</script> </script>
## <Languages size="18" class="mr-1 inline-block align-baseline" /> Oversættelse ## <Languages size="18" class="inline-block align-baseline" /> Translation
Hjemmesiden er oversat af frivillige ved hjælp af en kollaborativ oversættelsesplatform. 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>. You can contribute by adding or improving translations on our <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin project</a>.
+4 -4
View File
@@ -29,13 +29,13 @@ You can also drag and drop files directly from your file system into the window.
Create a copy of the currently selected files. Create a copy of the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Close ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
Close the currently selected files. Delete the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Close all ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
Close all files. Delete all files.
### <Download size="16" class="inline-block" style="margin-bottom: 2px" />Eksporter... ### <Download size="16" class="inline-block" style="margin-bottom: 2px" />Eksporter...
+2 -2
View File
@@ -3,7 +3,7 @@ title: Route planning and editing
--- ---
<script> <script>
import { Pencil, Route, Bike, TriangleAlert, ArrowRightLeft, Home, Repeat, Trash2, CirclePlay, SquareArrowUpLeft } from '@lucide/svelte'; import { Pencil, Route, Bike, TriangleAlert, ArrowRightLeft, House, Repeat, Trash2, CirclePlay, SquareArrowUpLeft } from '@lucide/svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte'; import DocsNote from '$lib/components/docs/DocsNote.svelte';
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte'; import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
import DocsImage from '$lib/components/docs/DocsImage.svelte'; import DocsImage from '$lib/components/docs/DocsImage.svelte';
@@ -71,7 +71,7 @@ The following tools automate some common route modification operations.
Reverse the direction of the route. Reverse the direction of the route.
### <Home size="16" class="inline-block" style="margin-bottom: 2px" /> Back to start ### <House size="16" class="inline-block" style="margin-bottom: 2px" /> Back to start
Connect the last point of the route with the starting point, using the chosen routing settings. Connect the last point of the route with the starting point, using the chosen routing settings.
+1 -1
View File
@@ -2,7 +2,7 @@
import { HeartHandshake } from '@lucide/svelte'; import { HeartHandshake } from '@lucide/svelte';
</script> </script>
## <HeartHandshake size="18" class="mr-1 inline-block align-baseline" /> Helfen Sie, die Website kostenlos (und werbefrei) zu erhalten ## <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. Jedes Mal, wenn Sie GPS-Punkte hinzufügen oder verschieben, berechnen unsere Server die beste Route im Straßennetz.
Wir verwenden auch APIs von <a href="https://mapbox.com" target="_blank">Mapbox</a>, um schöne Karten anzuzeigen, Höhendaten abzurufen und Ihnen die Suche nach Orten zu ermöglichen. Wir verwenden auch APIs von <a href="https://mapbox.com" target="_blank">Mapbox</a>, um schöne Karten anzuzeigen, Höhendaten abzurufen und Ihnen die Suche nach Orten zu ermöglichen.
+1 -1
View File
@@ -1,5 +1,5 @@
Mapbox ist das Unternehmen, das einige der schönen Karten auf dieser Website zur Verfügung stellt. Mapbox ist das Unternehmen, das einige der schönen Karten auf dieser Website zur Verfügung stellt.
Sie entwickeln auch die <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">Karten-Engine</a> welche **gpx.studio** unterstützt. Sie entwickeln auch die <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">Karten-Engine</a> welche **gpx.studio** unterstützt.
Wir sind äusserst glücklich und dankbar, Teil ihres <a href="https://mapbox.com/community" target="_blank">Community</a> Programms zu sein, das gemeinnützige Organisationen, Bildungseinrichtungen und Organisationen mit positivem Einfluss unterstützt. Wir sind äußerst glücklich und dankbar, Teil ihres <a href="https://mapbox.com/community" target="_blank">Community</a> Programms zu sein, das gemeinnützige Organisationen, Bildungseinrichtungen und Organisationen mit positivem Einfluss 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. 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 -1
View File
@@ -2,7 +2,7 @@
import { Languages } from '@lucide/svelte'; import { Languages } from '@lucide/svelte';
</script> </script>
## <Languages size="18" class="mr-1 inline-block align-baseline" /> Übersetzung ## <Languages size="18" class="inline-block align-baseline" /> Übersetzung
Die Webseite wird von Freiwilligen mit einer gemeinsamen Übersetzungsplattform übersetzt. 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. 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.
+4 -4
View File
@@ -29,13 +29,13 @@ Sie können auch Dateien per Drag-and-Drop aus Ihrem Dateisystem in das Fenster
Erstelle eine Kopie der aktuell ausgewählten Dateien. Erstelle eine Kopie der aktuell ausgewählten Dateien.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Schließen ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
Die aktuell ausgewählten Dateien schließen. Delete the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Alle schließen ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
Schließe alle Dateien. Delete all files.
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Exportieren... ### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Exportieren...
+2 -2
View File
@@ -3,7 +3,7 @@ title: Routenplanung und Bearbeitung
--- ---
<script> <script>
import { Pencil, Route, Bike, TriangleAlert, ArrowRightLeft, Home, Repeat, Trash2, CirclePlay, SquareArrowUpLeft } from '@lucide/svelte'; import { Pencil, Route, Bike, TriangleAlert, ArrowRightLeft, House, Repeat, Trash2, CirclePlay, SquareArrowUpLeft } from '@lucide/svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte'; import DocsNote from '$lib/components/docs/DocsNote.svelte';
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte'; import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
import DocsImage from '$lib/components/docs/DocsImage.svelte'; import DocsImage from '$lib/components/docs/DocsImage.svelte';
@@ -71,7 +71,7 @@ Die folgenden Tools automatisieren einige gemeinsame Routenmodifikationsoperatio
Die Richtung der Route umkehren. Die Richtung der Route umkehren.
### Zurück zum Start ### <House size="16" class="inline-block" style="margin-bottom: 2px" /> Zurück zum Start
Verbinden Sie den letzten Punkt der Route mit dem Startpunkt mit den gewählten Routing-Einstellungen. Verbinden Sie den letzten Punkt der Route mit dem Startpunkt mit den gewählten Routing-Einstellungen.
+1 -1
View File
@@ -2,7 +2,7 @@
import { HeartHandshake } from '@lucide/svelte'; import { HeartHandshake } from '@lucide/svelte';
</script> </script>
## <HeartHandshake size="18" class="mr-1 inline-block align-baseline" /> Help keep the website free (and ad-free) ## <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. 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. 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.
+1 -1
View File
@@ -2,7 +2,7 @@
import { Languages } from '@lucide/svelte'; import { Languages } from '@lucide/svelte';
</script> </script>
## <Languages size="18" class="mr-1 inline-block align-baseline" /> Translation ## <Languages size="18" class="inline-block align-baseline" /> Translation
The website is translated by volunteers using a collaborative translation platform. 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>. You can contribute by adding or improving translations on our <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin project</a>.

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