161 Commits

Author SHA1 Message Date
vcoppe
d18f77bd57 drop file tabs to desktop 2024-09-30 18:17:20 +02:00
vcoppe
d5022c3ce2 update dependencies 2024-09-30 14:14:43 +02:00
vcoppe
db881cbaf1 disable poi form inputs if no selection 2024-09-30 13:10:15 +02:00
vcoppe
4cacafa381 fix layout shift 2024-09-30 12:56:58 +02:00
vcoppe
c681029288 fix sitemap 2024-09-30 11:04:20 +02:00
vcoppe
f7d0bc1250 New Crowdin updates (#120)
* New translations file.mdx (Chinese Simplified)

* New translations settings.mdx (Chinese Simplified)

* New translations view.mdx (Chinese Simplified)

* New translations minify.mdx (Chinese Simplified)

* New translations poi.mdx (Chinese Simplified)

* New translations routing.mdx (Chinese Simplified)

* New translations scissors.mdx (Chinese Simplified)

* New translations time.mdx (Chinese Simplified)

* New translations faq.mdx (Chinese Simplified)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Korean)

* New translations en.json (Hebrew)

* New translations en.json (Finnish)

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

* New translations en.json (Belarusian)

* New translations en.json (Danish)

* New translations en.json (Latvian)

* Update source file en.json

* New translations en.json (French)

* New translations en.json (Dutch)

* New translations en.json (Spanish)

* New translations en.json (Korean)
2024-09-26 17:25:08 +02:00
vcoppe
ce974d7791 add multiple selection tip 2024-09-26 13:31:19 +02:00
vcoppe
01bf1274d9 New Crowdin updates (#119)
* New translations en.json (Czech)

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

* New translations getting-started.mdx (Czech)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Simplified)

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

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Simplified)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Korean)

* New translations en.json (Hebrew)

* New translations en.json (Finnish)

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

* New translations en.json (Belarusian)

* New translations en.json (Danish)

* New translations en.json (Latvian)

* Update source file en.json

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Korean)

* New translations en.json (Serbian (Latin))
2024-09-26 11:49:59 +02:00
vcoppe
01a29226e5 change page titles 2024-09-26 11:25:57 +02:00
vcoppe
c0ac148a97 New Crowdin updates (#118)
* New translations map-controls.mdx (Spanish)

* New translations map-controls.mdx (Dutch)
2024-09-24 17:43:07 +02:00
vcoppe
c6e4796cdb only set terrain when pitch > 0 2024-09-24 17:42:05 +02:00
vcoppe
eba6989606 change layers max zoom 2024-09-24 17:41:49 +02:00
vcoppe
eed13abeb0 remove embed pages and 404 from search results 2024-09-24 15:52:34 +02:00
vcoppe
c36636652b improve contrast 2024-09-24 15:16:33 +02:00
vcoppe
294ff5bedf revert to 2 step build 2024-09-24 14:50:44 +02:00
vcoppe
58415af7da New Crowdin updates (#117)
* New translations en.json (Italian)

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

* New translations getting-started.mdx (Romanian)

* New translations getting-started.mdx (French)

* New translations getting-started.mdx (Spanish)

* New translations getting-started.mdx (Catalan)

* New translations getting-started.mdx (Czech)

* New translations getting-started.mdx (German)

* New translations getting-started.mdx (Greek)

* New translations getting-started.mdx (Hungarian)

* New translations getting-started.mdx (Italian)

* New translations getting-started.mdx (Lithuanian)

* New translations getting-started.mdx (Dutch)

* New translations getting-started.mdx (Norwegian)

* New translations getting-started.mdx (Polish)

* New translations getting-started.mdx (Portuguese)

* New translations getting-started.mdx (Russian)

* New translations getting-started.mdx (Swedish)

* New translations getting-started.mdx (Chinese Simplified)

* New translations getting-started.mdx (Vietnamese)

* New translations getting-started.mdx (Portuguese, Brazilian)

* New translations map-controls.mdx (Romanian)

* New translations map-controls.mdx (French)

* New translations map-controls.mdx (Spanish)

* New translations map-controls.mdx (Catalan)

* New translations map-controls.mdx (Czech)

* New translations map-controls.mdx (German)

* New translations map-controls.mdx (Greek)

* New translations map-controls.mdx (Hungarian)

* New translations map-controls.mdx (Italian)

* New translations map-controls.mdx (Lithuanian)

* New translations map-controls.mdx (Dutch)

* New translations map-controls.mdx (Norwegian)

* New translations map-controls.mdx (Polish)

* New translations map-controls.mdx (Portuguese)

* New translations map-controls.mdx (Russian)

* New translations map-controls.mdx (Swedish)

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

* New translations map-controls.mdx (Vietnamese)

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

* New translations routing.mdx (Romanian)

* New translations routing.mdx (French)

* New translations routing.mdx (Spanish)

* New translations routing.mdx (Catalan)

* New translations routing.mdx (Czech)

* New translations routing.mdx (German)

* New translations routing.mdx (Greek)

* New translations routing.mdx (Hungarian)

* New translations routing.mdx (Italian)

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

* New translations routing.mdx (Vietnamese)

* New translations routing.mdx (Portuguese, Brazilian)

* New translations scissors.mdx (Romanian)

* New translations getting-started.mdx (Korean)

* New translations scissors.mdx (French)

* New translations scissors.mdx (Spanish)

* New translations scissors.mdx (Catalan)

* New translations scissors.mdx (Czech)

* New translations scissors.mdx (German)

* New translations scissors.mdx (Greek)

* New translations scissors.mdx (Hungarian)

* New translations scissors.mdx (Italian)

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

* New translations scissors.mdx (Vietnamese)

* New translations scissors.mdx (Portuguese, Brazilian)

* New translations map-controls.mdx (Korean)

* New translations getting-started.mdx (Hebrew)

* New translations routing.mdx (Korean)

* New translations scissors.mdx (Korean)

* New translations map-controls.mdx (Hebrew)

* New translations routing.mdx (Hebrew)

* New translations scissors.mdx (Hebrew)

* New translations getting-started.mdx (Finnish)

* New translations map-controls.mdx (Finnish)

* New translations routing.mdx (Finnish)

* New translations scissors.mdx (Finnish)

* New translations elevation.mdx (Italian)

* New translations getting-started.mdx (Serbian (Latin))

* New translations getting-started.mdx (Belarusian)

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

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

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

* New translations map-controls.mdx (Belarusian)

* New translations routing.mdx (Belarusian)

* New translations scissors.mdx (Belarusian)

* New translations getting-started.mdx (Danish)

* New translations getting-started.mdx (Latvian)

* New translations map-controls.mdx (Danish)

* New translations routing.mdx (Danish)

* New translations scissors.mdx (Danish)

* New translations map-controls.mdx (Latvian)

* New translations routing.mdx (Latvian)

* New translations scissors.mdx (Latvian)

* Update source file getting-started.mdx

* Update source file map-controls.mdx

* Update source file routing.mdx

* Update source file scissors.mdx

* New translations getting-started.mdx (Romanian)

* New translations getting-started.mdx (French)

* New translations getting-started.mdx (Spanish)

* New translations getting-started.mdx (Catalan)

* New translations getting-started.mdx (Czech)

* New translations getting-started.mdx (German)

* New translations getting-started.mdx (Greek)

* New translations getting-started.mdx (Hungarian)

* New translations getting-started.mdx (Italian)

* New translations getting-started.mdx (Lithuanian)

* New translations getting-started.mdx (Dutch)

* New translations getting-started.mdx (Norwegian)

* New translations getting-started.mdx (Polish)

* New translations getting-started.mdx (Portuguese)

* New translations getting-started.mdx (Russian)

* New translations getting-started.mdx (Swedish)

* New translations getting-started.mdx (Chinese Simplified)

* New translations getting-started.mdx (Vietnamese)

* New translations getting-started.mdx (Portuguese, Brazilian)

* New translations routing.mdx (Romanian)

* New translations routing.mdx (French)

* New translations routing.mdx (Spanish)

* New translations routing.mdx (Catalan)

* New translations routing.mdx (Czech)

* New translations routing.mdx (German)

* New translations routing.mdx (Greek)

* New translations routing.mdx (Hungarian)

* New translations routing.mdx (Italian)

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

* New translations routing.mdx (Vietnamese)

* New translations routing.mdx (Portuguese, Brazilian)

* New translations scissors.mdx (Romanian)

* New translations getting-started.mdx (Korean)

* New translations scissors.mdx (French)

* New translations scissors.mdx (Spanish)

* New translations scissors.mdx (Catalan)

* New translations scissors.mdx (Czech)

* New translations scissors.mdx (German)

* New translations scissors.mdx (Greek)

* New translations scissors.mdx (Hungarian)

* New translations scissors.mdx (Italian)

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

* New translations scissors.mdx (Vietnamese)

* New translations scissors.mdx (Portuguese, Brazilian)

* New translations getting-started.mdx (Hebrew)

* New translations routing.mdx (Korean)

* New translations scissors.mdx (Korean)

* New translations routing.mdx (Hebrew)

* New translations scissors.mdx (Hebrew)

* New translations getting-started.mdx (Finnish)

* New translations routing.mdx (Finnish)

* New translations scissors.mdx (Finnish)

* New translations getting-started.mdx (Serbian (Latin))

* New translations getting-started.mdx (Belarusian)

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

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

* New translations routing.mdx (Belarusian)

* New translations scissors.mdx (Belarusian)

* New translations getting-started.mdx (Danish)

* New translations getting-started.mdx (Latvian)

* New translations routing.mdx (Danish)

* New translations scissors.mdx (Danish)

* New translations routing.mdx (Latvian)

* New translations scissors.mdx (Latvian)

* Update source file getting-started.mdx

* Update source file routing.mdx

* Update source file scissors.mdx

* New translations getting-started.mdx (French)

* New translations getting-started.mdx (Spanish)

* New translations getting-started.mdx (German)

* New translations getting-started.mdx (Dutch)

* New translations getting-started.mdx (Russian)

* New translations getting-started.mdx (Portuguese, Brazilian)

* New translations map-controls.mdx (French)

* New translations routing.mdx (French)

* New translations routing.mdx (Spanish)

* New translations routing.mdx (Czech)

* New translations routing.mdx (Hungarian)

* New translations routing.mdx (Dutch)

* New translations routing.mdx (Russian)

* New translations routing.mdx (Portuguese, Brazilian)

* New translations scissors.mdx (Spanish)

* New translations scissors.mdx (Italian)

* New translations scissors.mdx (Portuguese, Brazilian)
2024-09-24 14:44:41 +02:00
vcoppe
369c2a5fb6 remove layer from map when removed from layer selection, maybe related to #110 2024-09-24 14:40:08 +02:00
vcoppe
9eb716e36c change name of img parameter 2024-09-24 13:50:44 +02:00
vcoppe
4c56468970 rely on gpx postinstall script to build the library 2024-09-24 13:45:30 +02:00
vcoppe
9a2541b6f3 try to improve core web vitals 2024-09-24 13:45:03 +02:00
vcoppe
2be5c837cb New Crowdin updates (#115)
* New translations files-and-stats.mdx (Dutch)

* New translations elevation.mdx (Italian)

* New translations en.json (Italian)

* New translations files-and-stats.mdx (Italian)
2024-09-24 11:05:39 +02:00
vcoppe
43803717f4 reduce example size 2024-09-24 11:03:14 +02:00
vcoppe
0d4376ee6f avoid more dynamic imports 2024-09-24 10:42:15 +02:00
vcoppe
7a72e3d44e New Crowdin updates (#114)
* Update source file files-and-stats.mdx

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Update source file files-and-stats.mdx

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

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

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

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

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

* New translations files-and-stats.mdx (Portuguese, Brazilian)
2024-09-23 19:16:21 +02:00
vcoppe
b8b74cc7de fix link 2024-09-23 19:03:50 +02:00
vcoppe
ea3d10fcc3 fix overlay opacity 2024-09-23 18:26:01 +02:00
vcoppe
45bfac4f88 New Crowdin updates (#113)
* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations map-controls.mdx (Dutch)

* New translations en.json (Korean)

* New translations en.json (Hebrew)

* New translations en.json (Finnish)

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

* New translations en.json (Belarusian)

* New translations en.json (Danish)

* New translations en.json (Latvian)

* Update source file en.json

* New translations en.json (French)
2024-09-23 18:01:46 +02:00
vcoppe
74d37f1d45 more aria labels 2024-09-23 17:46:10 +02:00
vcoppe
195671acb6 add aria labels 2024-09-23 16:41:12 +02:00
vcoppe
2e1ead31ea add width to logo 2024-09-23 16:20:47 +02:00
vcoppe
3af91213fe decrease gap between guide sections 2024-09-23 16:11:31 +02:00
vcoppe
6585f05ce3 New Crowdin updates (#111)
* New translations map-controls.mdx (Italian)

* New translations settings.mdx (Italian)

* New translations en.json (French)

* New translations en.json (Latvian)

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

* New translations getting-started.mdx (Latvian)

* New translations gpx.mdx (Latvian)

* New translations funding.mdx (Latvian)

* New translations mapbox.mdx (Latvian)

* New translations translation.mdx (Latvian)

* New translations integration.mdx (Latvian)

* New translations map-controls.mdx (Latvian)

* New translations menu.mdx (Latvian)

* New translations edit.mdx (Latvian)

* New translations file.mdx (Latvian)

* New translations settings.mdx (Latvian)

* New translations view.mdx (Latvian)

* New translations toolbar.mdx (Latvian)

* New translations clean.mdx (Latvian)

* New translations extract.mdx (Latvian)

* New translations merge.mdx (Latvian)

* New translations minify.mdx (Latvian)

* New translations poi.mdx (Latvian)

* New translations routing.mdx (Latvian)

* New translations scissors.mdx (Latvian)

* New translations time.mdx (Latvian)

* New translations faq.mdx (Latvian)

* New translations elevation.mdx (Latvian)

* New translations en.json (Latvian)

* New translations funding.mdx (Latvian)

* New translations translation.mdx (Latvian)

* New translations time.mdx (Latvian)

* New translations en.json (French)
2024-09-23 15:21:28 +02:00
vcoppe
bcc29480c7 preload all guide titles 2024-09-23 15:21:01 +02:00
vcoppe
c02d96e90f fix rounded docs img on safari 2024-09-23 14:55:38 +02:00
vcoppe
56e4522da7 fix surface 2024-09-22 13:05:28 +02:00
vcoppe
5592cf47e0 New Crowdin updates (#109)
* New translations edit.mdx (Italian)

* New translations edit.mdx (Italian)

* New translations en.json (Portuguese, Brazilian)

* New translations edit.mdx (Portuguese, Brazilian)

* New translations faq.mdx (Portuguese, Brazilian)

* New translations elevation.mdx (Portuguese, Brazilian)

* New translations map-controls.mdx (Romanian)

* New translations map-controls.mdx (French)

* New translations map-controls.mdx (Spanish)

* New translations map-controls.mdx (Catalan)

* New translations map-controls.mdx (Czech)

* New translations map-controls.mdx (German)

* New translations map-controls.mdx (Greek)

* New translations map-controls.mdx (Hungarian)

* New translations map-controls.mdx (Italian)

* New translations map-controls.mdx (Lithuanian)

* New translations map-controls.mdx (Dutch)

* New translations map-controls.mdx (Norwegian)

* New translations map-controls.mdx (Polish)

* New translations map-controls.mdx (Portuguese)

* New translations map-controls.mdx (Russian)

* New translations map-controls.mdx (Swedish)

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

* New translations map-controls.mdx (Vietnamese)

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

* New translations settings.mdx (Romanian)

* New translations settings.mdx (French)

* New translations settings.mdx (Spanish)

* New translations settings.mdx (Catalan)

* New translations settings.mdx (Czech)

* New translations settings.mdx (German)

* New translations settings.mdx (Greek)

* New translations settings.mdx (Hungarian)

* New translations settings.mdx (Italian)

* New translations settings.mdx (Lithuanian)

* New translations settings.mdx (Dutch)

* New translations settings.mdx (Norwegian)

* New translations settings.mdx (Polish)

* New translations settings.mdx (Portuguese)

* New translations settings.mdx (Russian)

* New translations settings.mdx (Swedish)

* New translations settings.mdx (Chinese Simplified)

* New translations settings.mdx (Vietnamese)

* New translations settings.mdx (Portuguese, Brazilian)

* New translations map-controls.mdx (Korean)

* New translations settings.mdx (Korean)

* New translations map-controls.mdx (Hebrew)

* New translations settings.mdx (Hebrew)

* New translations map-controls.mdx (Finnish)

* New translations settings.mdx (Finnish)

* New translations map-controls.mdx (Belarusian)

* New translations settings.mdx (Belarusian)

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

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

* New translations map-controls.mdx (Danish)

* New translations settings.mdx (Danish)

* Update source file map-controls.mdx

* Update source file settings.mdx

* New translations map-controls.mdx (French)

* New translations map-controls.mdx (Spanish)

* New translations map-controls.mdx (German)

* New translations map-controls.mdx (Dutch)

* New translations map-controls.mdx (Russian)

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

* New translations settings.mdx (French)

* New translations settings.mdx (Spanish)

* New translations settings.mdx (German)

* New translations settings.mdx (Dutch)

* New translations settings.mdx (Russian)

* New translations settings.mdx (Portuguese, Brazilian)
2024-09-22 11:43:15 +02:00
vcoppe
764c5030b9 consistent map layers icon 2024-09-22 11:28:13 +02:00
vcoppe
d4460f95dd improve layout shift and accessibility 2024-09-21 19:15:19 +02:00
vcoppe
1483460ec6 add missing buttons to default view 2024-09-21 16:42:13 +02:00
vcoppe
1a10ecc44b unique title for each docs page 2024-09-21 10:18:01 +02:00
vcoppe
f77793b7fe non-blocking docs load 2024-09-20 14:22:55 +02:00
vcoppe
a48da3fcf0 remove debugging info 2024-09-20 13:22:18 +02:00
vcoppe
9d13e9bcdc prerender mdx components 2024-09-20 13:22:05 +02:00
vcoppe
484aeedbb1 New translations clean.mdx (Dutch) (#107) 2024-09-20 12:42:48 +02:00
vcoppe
534b1ca8db New Crowdin updates (#106)
* New translations map-controls.mdx (Dutch)

* New translations toolbar.mdx (Dutch)

* New translations poi.mdx (Dutch)
2024-09-20 11:31:59 +02:00
vcoppe
4d16efe62f New Crowdin updates (#105)
* New translations files-and-stats.mdx (Lithuanian)

* New translations files-and-stats.mdx (Portuguese, Brazilian)
2024-09-20 11:15:10 +02:00
vcoppe
d3c11f6153 New translations files-and-stats.mdx (Dutch) (#104) 2024-09-20 11:00:19 +02:00
vcoppe
2c62abd3eb New translations files-and-stats.mdx (French) (#103) 2024-09-20 10:48:58 +02:00
vcoppe
a735852898 New Crowdin updates (#102)
* New translations en.json (Belarusian)

* New translations menu.mdx (Belarusian)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Update source file files-and-stats.mdx

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

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

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

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

* New translations files-and-stats.mdx (Portuguese, Brazilian)
2024-09-20 10:29:35 +02:00
vcoppe
f94edf3e3a fix urls 2024-09-20 10:15:28 +02:00
vcoppe
930b4b84ed remove debugging info 2024-09-20 08:57:04 +02:00
vcoppe
553bc2e0a3 New translations edit.mdx (Spanish) (#101) 2024-09-19 10:36:02 +02:00
vcoppe
aa50f1f2b0 rework map bounds logic when opening files from url 2024-09-19 10:35:02 +02:00
vcoppe
a27de23fa4 New Crowdin updates (#98)
* New translations en.json (Spanish)

* New translations en.json (Finnish)

* New translations en.json (Finnish)

* New translations en.json (Danish)

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

* New translations getting-started.mdx (Danish)

* New translations gpx.mdx (Danish)

* New translations funding.mdx (Danish)

* New translations mapbox.mdx (Danish)

* New translations translation.mdx (Danish)

* New translations integration.mdx (Danish)

* New translations map-controls.mdx (Danish)

* New translations menu.mdx (Danish)

* New translations edit.mdx (Danish)

* New translations file.mdx (Danish)

* New translations settings.mdx (Danish)

* New translations view.mdx (Danish)

* New translations toolbar.mdx (Danish)

* New translations clean.mdx (Danish)

* New translations extract.mdx (Danish)

* New translations merge.mdx (Danish)

* New translations minify.mdx (Danish)

* New translations poi.mdx (Danish)

* New translations routing.mdx (Danish)

* New translations scissors.mdx (Danish)

* New translations time.mdx (Danish)

* New translations faq.mdx (Danish)

* New translations elevation.mdx (Danish)

* New translations edit.mdx (Danish)

* New translations file.mdx (Danish)

* New translations faq.mdx (Danish)

* New translations toolbar.mdx (Belarusian)

* New translations toolbar.mdx (Belarusian)

* New translations toolbar.mdx (Belarusian)

* New translations edit.mdx (Romanian)

* New translations edit.mdx (French)

* New translations edit.mdx (Spanish)

* New translations edit.mdx (Catalan)

* New translations edit.mdx (Czech)

* New translations edit.mdx (German)

* New translations edit.mdx (Greek)

* New translations edit.mdx (Hungarian)

* New translations edit.mdx (Italian)

* New translations edit.mdx (Lithuanian)

* New translations edit.mdx (Dutch)

* New translations edit.mdx (Norwegian)

* New translations edit.mdx (Polish)

* New translations edit.mdx (Portuguese)

* New translations edit.mdx (Russian)

* New translations edit.mdx (Swedish)

* New translations edit.mdx (Chinese Simplified)

* New translations edit.mdx (Vietnamese)

* New translations edit.mdx (Portuguese, Brazilian)

* New translations edit.mdx (Korean)

* New translations edit.mdx (Hebrew)

* New translations edit.mdx (Finnish)

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

* New translations edit.mdx (Belarusian)

* New translations edit.mdx (Danish)

* Update source file edit.mdx

* New translations edit.mdx (French)

* New translations edit.mdx (Dutch)
2024-09-18 17:28:48 +02:00
vcoppe
deedd8c6c2 reset geocoder suggestions when input changes, closes #99 2024-09-18 17:12:08 +02:00
vcoppe
5c4181498d add new track/segment to edit menu, and to docs 2024-09-18 16:09:50 +02:00
vcoppe
29a78e8af3 more refactoring 2024-09-16 14:03:06 +02:00
vcoppe
4ebf2b6fa9 parameterize slope segment function 2024-09-16 13:52:41 +02:00
vcoppe
1b741c3b2f revert geojson import 2024-09-16 11:38:47 +02:00
vcoppe
81484789b5 import geojson types explicitly 2024-09-16 11:27:38 +02:00
vcoppe
c63e5cfb6b remove dist folder 2024-09-16 11:12:31 +02:00
vcoppe
8e37a308c3 New Crowdin updates (#96)
* New translations en.json (Catalan)

* New translations en.json (Catalan)

* New translations map-controls.mdx (Spanish)

* New translations map-controls.mdx (German)

* New translations map-controls.mdx (Italian)

* New translations map-controls.mdx (Polish)

* New translations map-controls.mdx (Portuguese)

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

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

* New translations settings.mdx (Chinese Simplified)

* New translations en.json (Italian)

* New translations en.json (Finnish)

* New translations en.json (Finnish)

* New translations en.json (Finnish)

* New translations en.json (Finnish)

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

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

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Simplified)

* New translations en.json (Finnish)

* New translations en.json (Finnish)

* New translations en.json (Belarusian)

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

* New translations getting-started.mdx (Belarusian)

* New translations gpx.mdx (Belarusian)

* New translations funding.mdx (Belarusian)

* New translations mapbox.mdx (Belarusian)

* New translations translation.mdx (Belarusian)

* New translations integration.mdx (Belarusian)

* New translations map-controls.mdx (Belarusian)

* New translations menu.mdx (Belarusian)

* New translations edit.mdx (Belarusian)

* New translations file.mdx (Belarusian)

* New translations settings.mdx (Belarusian)

* New translations view.mdx (Belarusian)

* New translations toolbar.mdx (Belarusian)

* New translations clean.mdx (Belarusian)

* New translations extract.mdx (Belarusian)

* New translations merge.mdx (Belarusian)

* New translations minify.mdx (Belarusian)

* New translations poi.mdx (Belarusian)

* New translations routing.mdx (Belarusian)

* New translations scissors.mdx (Belarusian)

* New translations time.mdx (Belarusian)

* New translations faq.mdx (Belarusian)

* New translations elevation.mdx (Belarusian)

* New translations en.json (Italian)

* New translations en.json (Finnish)

* New translations en.json (Finnish)

* New translations en.json (Finnish)

* New translations en.json (Finnish)
2024-09-16 10:57:49 +02:00
vcoppe
0baa956160 commit dist folder 2024-09-16 10:57:10 +02:00
vcoppe
b638863df3 New Crowdin updates (#94)
* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations translation.mdx (Hungarian)

* New translations edit.mdx (Hungarian)

* New translations clean.mdx (Hungarian)

* New translations merge.mdx (Hungarian)

* New translations minify.mdx (Hungarian)

* New translations en.json (Korean)

* New translations en.json (Hebrew)

* New translations en.json (Finnish)

* New translations elevation.mdx (Hungarian)

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

* New translations en.json (Catalan)

* New translations edit.mdx (Hungarian)

* New translations file.mdx (Hungarian)

* New translations poi.mdx (Hungarian)

* New translations routing.mdx (Hungarian)
2024-09-13 18:49:24 +02:00
Pierre Donias
ec7629aea7 Import routes elevation and time information (#95)
* Import routes elevation and time information

* map routepoint to trackpoint explicitly

---------

Co-authored-by: vcoppe <vianney.coppe@gmail.com>
2024-09-13 18:48:22 +02:00
vcoppe
3c7f78ae38 fix search box language 2024-09-13 15:54:56 +02:00
vcoppe
4ca749d1cc New Crowdin updates (#93)
* Update source file en.json

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Korean)

* New translations en.json (Hebrew)

* New translations en.json (Finnish)

* New translations en.json (Serbian (Latin))
2024-09-13 15:17:34 +02:00
vcoppe
0883bfed03 remove personalized search 2024-09-13 15:10:51 +02:00
vcoppe
130c12bb73 New Crowdin updates (#89)
* New translations en.json (Polish)

* New translations en.json (Italian)

* New translations en.json (Finnish)

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

* New translations getting-started.mdx (Finnish)

* New translations gpx.mdx (Finnish)

* New translations funding.mdx (Finnish)

* New translations mapbox.mdx (Finnish)

* New translations translation.mdx (Finnish)

* New translations integration.mdx (Finnish)

* New translations map-controls.mdx (Finnish)

* New translations menu.mdx (Finnish)

* New translations edit.mdx (Finnish)

* New translations file.mdx (Finnish)

* New translations settings.mdx (Finnish)

* New translations view.mdx (Finnish)

* New translations toolbar.mdx (Finnish)

* New translations clean.mdx (Finnish)

* New translations extract.mdx (Finnish)

* New translations merge.mdx (Finnish)

* New translations minify.mdx (Finnish)

* New translations poi.mdx (Finnish)

* New translations routing.mdx (Finnish)

* New translations scissors.mdx (Finnish)

* New translations time.mdx (Finnish)

* New translations faq.mdx (Finnish)

* New translations elevation.mdx (Finnish)

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

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

* New translations getting-started.mdx (Serbian (Latin))

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* New translations en.json (Italian)

* New translations clean.mdx (Catalan)

* New translations elevation.mdx (Catalan)

* New translations settings.mdx (Catalan)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Korean)

* New translations en.json (Hebrew)

* New translations en.json (Finnish)

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

* Update source file en.json

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Korean)

* New translations en.json (Hebrew)

* New translations en.json (Finnish)

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

* Update source file en.json

* New translations en.json (French)
2024-09-13 14:25:31 +02:00
vcoppe
f513aa28ab small language changes 2024-09-13 14:10:21 +02:00
vcoppe
5e1244cc82 small language changes 2024-09-13 14:07:48 +02:00
vcoppe
4c17c3ddfe Merge branch 'main' into dev 2024-09-13 14:02:15 +02:00
Pierre Donias
5236fc5191 Tab name: also fallback to filename when metadata "name" tag exists but is empty (#92) 2024-09-13 13:59:25 +02:00
vcoppe
1d443f0626 algolia docsearch 2024-09-13 13:58:48 +02:00
vcoppe
c83b32e6ae add reddit links 2024-09-13 13:56:28 +02:00
vcoppe
7adf660b76 use dvh only if supported 2024-09-12 17:10:28 +02:00
vcoppe
9a0d54c684 do not use locale time string, gives different formats and causes errors 2024-09-12 16:05:54 +02:00
vcoppe
3e57bdc7c8 only put all dictionary strings for app page 2024-09-12 12:15:26 +02:00
vcoppe
3cc4d569f1 fix ctrl+click on tab, relates to #91 2024-09-12 11:13:55 +02:00
vcoppe
dc76c71ae2 fix ctrl+click on gpx layer, relates to #91 2024-09-12 10:13:03 +02:00
vcoppe
1190a471fb set correct lang attribute, and prevent google translate 2024-09-12 09:59:23 +02:00
vcoppe
84b1a42e30 fix time export 2024-09-11 15:08:44 +02:00
vcoppe
08ad9b4186 prevent default on undo-redo 2024-09-11 14:41:02 +02:00
vcoppe
b8128aaf86 fix export bug 2024-09-10 20:17:33 +02:00
vcoppe
fb00220cc8 fix logo shrinking 2024-09-10 20:15:36 +02:00
vcoppe
723a0138b6 add text content in static html for seo 2024-09-10 18:51:08 +02:00
vcoppe
10e328f2a3 fix some empty actions 2024-09-10 17:42:36 +02:00
vcoppe
a1383a7e97 rename track when renaming file if it only contains one track, closes #75 2024-09-10 16:52:21 +02:00
vcoppe
56e229f000 remove empty elements from output 2024-09-10 16:45:37 +02:00
vcoppe
8511a18de1 New Crowdin updates (#86)
* New translations translation.mdx (Chinese Simplified)

* New translations settings.mdx (Chinese Simplified)

* New translations settings.mdx (Chinese Simplified)

* New translations en.json (Czech)

* New translations en.json (Czech)

* New translations minify.mdx (Czech)

* New translations poi.mdx (Czech)

* New translations routing.mdx (Czech)

* New translations map-controls.mdx (French)

* New translations edit.mdx (Dutch)

* New translations settings.mdx (Dutch)

* New translations routing.mdx (Dutch)
2024-09-09 19:44:07 +02:00
vcoppe
4b0e49d171 register map object in window for extensions 2024-09-09 19:43:22 +02:00
vcoppe
14e9d3319d New Crowdin updates (#85)
* New translations en.json (Spanish)

* New translations elevation.mdx (Spanish)
2024-09-06 15:50:33 +02:00
vcoppe
39e6532c26 use nominatim for geocoding 2024-09-06 15:49:12 +02:00
vcoppe
b343123ca6 remove console.log 2024-09-06 14:04:15 +02:00
vcoppe
3246742437 New Crowdin updates (#84)
* New translations en.json (Spanish)

* New translations en.json (Dutch)

* New translations en.json (Russian)

* New translations menu.mdx (Russian)

* New translations toolbar.mdx (Russian)

* New translations elevation.mdx (Dutch)

* New translations en.json (Russian)

* New translations en.json (French)

* New translations map-controls.mdx (Russian)

* New translations menu.mdx (Russian)

* New translations en.json (Russian)

* New translations mapbox.mdx (Russian)

* New translations integration.mdx (Russian)

* New translations map-controls.mdx (Russian)

* New translations view.mdx (Russian)

* New translations poi.mdx (Russian)

* New translations routing.mdx (Russian)

* New translations time.mdx (Russian)

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

* New translations getting-started.mdx (Russian)

* New translations gpx.mdx (Russian)

* New translations funding.mdx (Russian)

* New translations translation.mdx (Russian)

* New translations map-controls.mdx (Russian)

* New translations menu.mdx (Russian)

* New translations edit.mdx (Russian)

* New translations settings.mdx (Russian)

* New translations toolbar.mdx (Russian)

* New translations faq.mdx (Russian)

* New translations elevation.mdx (Russian)
2024-09-06 13:53:46 +02:00
vcoppe
3ce5391658 simplify specifying drive ids 2024-09-06 13:53:14 +02:00
vcoppe
71c88b15c6 small ui improvements 2024-09-06 13:36:36 +02:00
vcoppe
25a3df5756 fix time input for iOS 2024-09-05 10:23:34 +02:00
vcoppe
9d5391805d fix consecutive splits 2024-09-05 09:51:43 +02:00
vcoppe
7d801be682 handle elevation tile request failure 2024-09-05 09:41:14 +02:00
vcoppe
f846b03e74 fix adding elevation to multiple files 2024-09-04 22:15:46 +02:00
vcoppe
e48789bb75 New Crowdin updates (#83)
* New translations en.json (Catalan)

* New translations faq.mdx (Spanish)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Korean)

* New translations en.json (Hebrew)

* New translations faq.mdx (Romanian)

* New translations faq.mdx (French)

* New translations faq.mdx (Spanish)

* New translations faq.mdx (Catalan)

* New translations faq.mdx (Czech)

* New translations faq.mdx (German)

* New translations faq.mdx (Greek)

* New translations faq.mdx (Hebrew)

* New translations faq.mdx (Hungarian)

* New translations faq.mdx (Italian)

* New translations faq.mdx (Korean)

* New translations faq.mdx (Lithuanian)

* New translations faq.mdx (Dutch)

* New translations faq.mdx (Norwegian)

* New translations faq.mdx (Polish)

* New translations faq.mdx (Portuguese)

* New translations faq.mdx (Russian)

* New translations faq.mdx (Swedish)

* New translations faq.mdx (Chinese Simplified)

* New translations faq.mdx (Vietnamese)

* New translations faq.mdx (Portuguese, Brazilian)

* New translations elevation.mdx (Romanian)

* New translations elevation.mdx (French)

* New translations elevation.mdx (Spanish)

* New translations elevation.mdx (Catalan)

* New translations elevation.mdx (Czech)

* New translations elevation.mdx (German)

* New translations elevation.mdx (Greek)

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

* New translations elevation.mdx (Vietnamese)

* New translations elevation.mdx (Portuguese, Brazilian)

* Update source file en.json

* Update source file faq.mdx

* Update source file elevation.mdx

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Korean)

* New translations en.json (Hebrew)

* New translations elevation.mdx (French)

* Update source file en.json

* New translations en.json (French)

* New translations en.json (Spanish)
2024-09-04 21:53:06 +02:00
vcoppe
30cc709627 add link to docs 2024-09-04 19:39:22 +02:00
vcoppe
9c85a014da clarify elevation vs elevation gain/loss 2024-09-04 19:34:28 +02:00
vcoppe
f55a3c0224 put origin info in note 2024-09-04 19:15:36 +02:00
vcoppe
8985623639 add elevation tool 2024-09-04 19:11:56 +02:00
vcoppe
9ba07ce1ed Merge branch 'dev' into elevation-tool 2024-09-04 15:18:44 +02:00
vcoppe
2e50dea71d fix loading files without metadata 2024-09-04 14:49:10 +02:00
vcoppe
0757efcb79 get all previous point additional data when inserting new anchor 2024-09-04 14:46:08 +02:00
vcoppe
5be02d1c36 add new anchors by clicking on the temporary one 2024-09-04 14:42:26 +02:00
vcoppe
2612eb2e91 update selected routing profile label when locale changes 2024-09-04 13:36:19 +02:00
vcoppe
2996f047d3 fix embedding playground update 2024-09-04 13:26:00 +02:00
vcoppe
96836228db remove custom layer import only if present 2024-09-04 13:15:36 +02:00
vcoppe
a3dc17d780 decode custom layer urls 2024-09-04 12:45:40 +02:00
vcoppe
e733c96c5a New Crowdin updates (#81)
* Update source file faq.mdx

* New translations en.json (Italian)

* New translations faq.mdx (Romanian)

* New translations faq.mdx (French)

* New translations faq.mdx (Spanish)

* New translations faq.mdx (Catalan)

* New translations faq.mdx (Czech)

* New translations faq.mdx (German)

* New translations faq.mdx (Greek)

* New translations faq.mdx (Hebrew)

* New translations faq.mdx (Hungarian)

* New translations faq.mdx (Italian)

* New translations faq.mdx (Korean)

* New translations faq.mdx (Lithuanian)

* New translations faq.mdx (Dutch)

* New translations faq.mdx (Norwegian)

* New translations faq.mdx (Polish)

* New translations faq.mdx (Portuguese)

* New translations faq.mdx (Russian)

* New translations faq.mdx (Swedish)

* New translations faq.mdx (Chinese Simplified)

* New translations faq.mdx (Vietnamese)

* New translations faq.mdx (Portuguese, Brazilian)

* New translations faq.mdx (Romanian)

* New translations faq.mdx (French)

* New translations faq.mdx (Spanish)

* New translations faq.mdx (Catalan)

* New translations faq.mdx (Czech)

* New translations faq.mdx (German)

* New translations faq.mdx (Greek)

* New translations faq.mdx (Hebrew)

* New translations faq.mdx (Hungarian)

* New translations faq.mdx (Italian)

* New translations faq.mdx (Korean)

* New translations faq.mdx (Lithuanian)

* New translations faq.mdx (Dutch)

* New translations faq.mdx (Norwegian)

* New translations faq.mdx (Polish)

* New translations faq.mdx (Portuguese)

* New translations faq.mdx (Russian)

* New translations faq.mdx (Swedish)

* New translations faq.mdx (Chinese Simplified)

* New translations faq.mdx (Vietnamese)

* New translations faq.mdx (Portuguese, Brazilian)

* Update source file faq.mdx

* New translations faq.mdx (Romanian)

* New translations faq.mdx (French)

* New translations faq.mdx (Spanish)

* New translations faq.mdx (Catalan)

* New translations faq.mdx (Czech)

* New translations faq.mdx (German)

* New translations faq.mdx (Greek)

* New translations faq.mdx (Hebrew)

* New translations faq.mdx (Hungarian)

* New translations faq.mdx (Italian)

* New translations faq.mdx (Korean)

* New translations faq.mdx (Lithuanian)

* New translations faq.mdx (Dutch)

* New translations faq.mdx (Norwegian)

* New translations faq.mdx (Polish)

* New translations faq.mdx (Portuguese)

* New translations faq.mdx (Russian)

* New translations faq.mdx (Swedish)

* New translations faq.mdx (Chinese Simplified)

* New translations faq.mdx (Vietnamese)

* New translations faq.mdx (Portuguese, Brazilian)

* Update source file faq.mdx

* New translations faq.mdx (Romanian)

* New translations faq.mdx (French)

* New translations faq.mdx (Spanish)

* New translations faq.mdx (Catalan)

* New translations faq.mdx (Czech)

* New translations faq.mdx (German)

* New translations faq.mdx (Greek)

* New translations faq.mdx (Hebrew)

* New translations faq.mdx (Hungarian)

* New translations faq.mdx (Italian)

* New translations faq.mdx (Korean)

* New translations faq.mdx (Lithuanian)

* New translations faq.mdx (Dutch)

* New translations faq.mdx (Norwegian)

* New translations faq.mdx (Polish)

* New translations faq.mdx (Portuguese)

* New translations faq.mdx (Russian)

* New translations faq.mdx (Swedish)

* New translations faq.mdx (Chinese Simplified)

* New translations faq.mdx (Vietnamese)

* New translations faq.mdx (Portuguese, Brazilian)

* Update source file faq.mdx

* New translations en.json (Hungarian)

* New translations en.json (Dutch)

* New translations faq.mdx (French)

* New translations faq.mdx (Dutch)

* New translations en.json (Hungarian)

* New translations faq.mdx (Italian)

* New translations en.json (Hungarian)

* New translations en.json (Spanish)

* New translations faq.mdx (Spanish)

* New translations en.json (German)

* New translations getting-started.mdx (German)

* New translations map-controls.mdx (German)

* New translations menu.mdx (German)

* New translations toolbar.mdx (German)
2024-09-04 11:55:05 +02:00
vcoppe
1de5e9443e fix compatibility issues with basecamp 2024-09-04 11:53:45 +02:00
vcoppe
481bc3b8a1 more rephrasing in faq 2024-09-03 18:47:58 +02:00
vcoppe
ce5b0d87a9 more rephrasing in faq 2024-09-03 18:46:43 +02:00
vcoppe
efa40edc80 more rephrasing in faq 2024-09-03 18:45:41 +02:00
vcoppe
8497044473 rephrase faq section 2024-09-03 18:36:17 +02:00
vcoppe
41ed9c06c1 add faq section 2024-09-03 15:51:15 +02:00
vcoppe
75dc8512e7 New Crowdin updates (#80)
* New translations en.json (Chinese Simplified)

* New translations toolbar.mdx (German)

* New translations funding.mdx (Spanish)

* New translations merge.mdx (German)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Korean)

* New translations en.json (Hebrew)
2024-09-03 14:51:06 +02:00
vcoppe
979cdd1bac fix error when adding custom overlay 2024-09-03 14:43:20 +02:00
vcoppe
056e7a3980 improve swedish maps 2024-09-03 12:45:35 +02:00
vcoppe
47df6d8f80 update readme 2024-09-02 13:50:27 +02:00
vcoppe
3cbfaba5e7 change base path 2024-09-02 12:32:33 +02:00
vcoppe
24181b932a New Crowdin updates (#77)
* New translations en.json (Dutch)

* New translations en.json (Spanish)
2024-09-02 12:27:12 +02:00
vcoppe
666693f374 New Crowdin updates (#76)
* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Korean)

* New translations en.json (Hebrew)
2024-08-31 16:05:08 +02:00
vcoppe
0cb781176e use style imports instead of layers to allow stacking mapbox styles, closes #32 2024-08-31 15:57:58 +02:00
vcoppe
33f3b6cc32 New Crowdin updates (#74)
* New translations en.json (Italian)

* New translations edit.mdx (Italian)

* New translations en.json (Spanish)

* New translations en.json (Italian)

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

* New translations getting-started.mdx (Italian)

* New translations integration.mdx (Italian)

* New translations map-controls.mdx (Italian)

* New translations edit.mdx (Spanish)

* New translations file.mdx (Italian)

* New translations settings.mdx (Italian)

* New translations view.mdx (Italian)

* New translations toolbar.mdx (Italian)

* New translations minify.mdx (Italian)

* New translations poi.mdx (Italian)

* New translations routing.mdx (Italian)

* New translations scissors.mdx (Italian)

* New translations edit.mdx (Italian)

* New translations en.json (Romanian)

* New translations en.json (Dutch)

* New translations edit.mdx (Dutch)
2024-08-29 19:03:15 +02:00
vcoppe
a1b5fe6352 realistic timestamps option 2024-08-29 18:37:06 +02:00
vcoppe
920e7901f4 remove disclaimer 2024-08-29 14:10:40 +02:00
vcoppe
a23fea3d98 fix split control icon color for dark theme 2024-08-26 17:57:22 +02:00
vcoppe
a751ada6c5 New Crowdin updates (#63)
* New translations integration.mdx (Dutch)

* New translations funding.mdx (Vietnamese)

* New translations en.json (Italian)

* New translations en.json (Italian)

* New translations getting-started.mdx (Italian)

* New translations gpx.mdx (Italian)

* New translations poi.mdx (Italian)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Portuguese, Brazilian)

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

* New translations integration.mdx (Portuguese, Brazilian)

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

* New translations routing.mdx (Portuguese, Brazilian)

* New translations file.mdx (Italian)

* New translations integration.mdx (Italian)

* New translations file.mdx (Italian)

* New translations settings.mdx (Italian)

* New translations view.mdx (Italian)

* New translations toolbar.mdx (Italian)

* New translations routing.mdx (Italian)

* New translations scissors.mdx (Italian)

* New translations time.mdx (Italian)

* New translations view.mdx (Italian)

* New translations getting-started.mdx (Italian)

* New translations map-controls.mdx (Italian)

* New translations toolbar.mdx (Italian)

* New translations file.mdx (Italian)

* New translations settings.mdx (Italian)

* New translations en.json (Spanish)

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

* New translations integration.mdx (Spanish)

* New translations en.json (Korean)

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

* New translations getting-started.mdx (Korean)

* New translations gpx.mdx (Korean)

* New translations funding.mdx (Korean)

* New translations mapbox.mdx (Korean)

* New translations translation.mdx (Korean)

* New translations integration.mdx (Korean)

* New translations map-controls.mdx (Korean)

* New translations menu.mdx (Korean)

* New translations edit.mdx (Korean)

* New translations file.mdx (Korean)

* New translations settings.mdx (Korean)

* New translations view.mdx (Korean)

* New translations toolbar.mdx (Korean)

* New translations clean.mdx (Korean)

* New translations extract.mdx (Korean)

* New translations merge.mdx (Korean)

* New translations minify.mdx (Korean)

* New translations poi.mdx (Korean)

* New translations routing.mdx (Korean)

* New translations scissors.mdx (Korean)

* New translations time.mdx (Korean)

* New translations en.json (Korean)

* New translations en.json (Korean)

* New translations translation.mdx (Korean)

* New translations settings.mdx (Korean)

* New translations en.json (Korean)

* New translations en.json (Polish)

* New translations poi.mdx (Italian)

* New translations poi.mdx (Italian)

* New translations scissors.mdx (Italian)

* New translations en.json (Polish)

* New translations menu.mdx (Polish)

* New translations en.json (Polish)

* New translations funding.mdx (Polish)

* New translations mapbox.mdx (Polish)

* New translations translation.mdx (Polish)

* New translations settings.mdx (Polish)

* New translations en.json (Polish)

* New translations edit.mdx (Polish)

* New translations map-controls.mdx (Italian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Korean)

* New translations en.json (Hebrew)

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

* New translations getting-started.mdx (Hebrew)

* New translations gpx.mdx (Hebrew)

* New translations funding.mdx (Hebrew)

* New translations mapbox.mdx (Hebrew)

* New translations translation.mdx (Hebrew)

* New translations integration.mdx (Hebrew)

* New translations map-controls.mdx (Hebrew)

* New translations menu.mdx (Hebrew)

* New translations edit.mdx (Hebrew)

* New translations file.mdx (Hebrew)

* New translations settings.mdx (Hebrew)

* New translations view.mdx (Hebrew)

* New translations toolbar.mdx (Hebrew)

* New translations clean.mdx (Hebrew)

* New translations extract.mdx (Hebrew)

* New translations merge.mdx (Hebrew)

* New translations minify.mdx (Hebrew)

* New translations poi.mdx (Hebrew)

* New translations routing.mdx (Hebrew)

* New translations scissors.mdx (Hebrew)

* New translations time.mdx (Hebrew)

* Update source file en.json

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Korean)

* New translations en.json (Hebrew)

* Update source file en.json

* New translations en.json (French)

* New translations edit.mdx (Romanian)

* New translations edit.mdx (French)

* New translations edit.mdx (Spanish)

* New translations edit.mdx (Catalan)

* New translations edit.mdx (Czech)

* New translations edit.mdx (German)

* New translations edit.mdx (Greek)

* New translations edit.mdx (Hungarian)

* New translations edit.mdx (Italian)

* New translations edit.mdx (Lithuanian)

* New translations edit.mdx (Dutch)

* New translations edit.mdx (Norwegian)

* New translations edit.mdx (Polish)

* New translations edit.mdx (Portuguese)

* New translations edit.mdx (Russian)

* New translations edit.mdx (Swedish)

* New translations edit.mdx (Chinese Simplified)

* New translations edit.mdx (Vietnamese)

* New translations edit.mdx (Portuguese, Brazilian)

* New translations edit.mdx (Korean)

* New translations edit.mdx (Hebrew)

* Update source file edit.mdx

* New translations edit.mdx (French)
2024-08-26 17:29:47 +02:00
vcoppe
1cf1ce762e Merge branch 'dev' of https://github.com/gpxstudio/gpx.studio into dev 2024-08-26 14:27:24 +02:00
vcoppe
833f3e58da add center action to docs 2024-08-26 14:27:22 +02:00
mbof
b9ca55c798 Implement fit-to-track (#43) (#69)
* Implement fit-to-track (#43)

* * Change icon to Maximize
* Use default animation
* Rename feature to "Fly to"
* Move logic to stores so that it can be reused in the main menu (and eventually a keyboard shortcut)

* improve zoom on selected POIs, rework zoom buttons and add shortcut

---------

Co-authored-by: vcoppe <vianney.coppe@gmail.com>
2024-08-26 14:00:53 +02:00
mbof
766ebe0275 Add support for nautical units (#61) (#66)
* Add support for nautical units

* Make panel reactive to unit changes even after pushing down conversions into utility functions.

* Fix issue: km->mph conversion was not done right.

* Add support for nautical units to the Time dialog.

* Add support for nautical units to the embedding view and embedding playground.

* add missing parameter and rename

* "npx prettier" pass on the files changed in this PR

Does not include changes to `website/src/lib/db.ts`, because there would otherwise be lots of unrelated formatting changes

* Change elevation unit to meters for 'nautical' distances.

* hide elevation decimals

---------

Co-authored-by: bdbkun <1308709+mbof@users.noreply.github.com>
Co-authored-by: vcoppe <vianney.coppe@gmail.com>
2024-08-26 12:51:05 +02:00
mbof
d939ef2f60 Set interpolation mode to "monotone" to avoid backwards-going lines (fix #70) (#71) 2024-08-26 12:24:45 +02:00
vcoppe
c1faea787a block delete shortcut when focusing an input #68 2024-08-23 10:31:02 +02:00
vcoppe
e42dd6e144 fix stats slice bug, closes #67 2024-08-22 14:56:39 +02:00
vcoppe
e10e2412c4 fix timestamp edition for files with missing ones 2024-08-22 11:33:11 +02:00
vcoppe
b5fd8ea09b refine routing controls interactions 2024-08-22 10:41:04 +02:00
vcoppe
1a4ae96782 fix routing anchor visibility for any bearing 2024-08-19 12:55:17 +02:00
vcoppe
14ed58aaab show duplicate button in horizontal list, closes #64 2024-08-19 12:03:08 +02:00
vcoppe
779c700d13 ordnance survey vector version 2024-08-19 11:46:47 +02:00
vcoppe
31a2aa2fee fix embedding redirect 2024-08-16 13:08:29 +02:00
vcoppe
9d403c861f fix embedding redirect 2024-08-16 12:58:24 +02:00
vcoppe
49582dcddd fix some URLs 2024-08-16 12:51:55 +02:00
vcoppe
fa30739fd0 inject static meta tags for each language 2024-08-16 12:25:24 +02:00
vcoppe
3bc9ac4639 New Crowdin updates (#60)
* New translations gpx.mdx (Italian)

* New translations funding.mdx (Italian)

* New translations clean.mdx (Italian)

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

* New translations funding.mdx (Hungarian)

* New translations funding.mdx (Hungarian)

* New translations mapbox.mdx (Hungarian)

* New translations translation.mdx (Hungarian)

* New translations settings.mdx (Hungarian)

* New translations clean.mdx (Italian)

* New translations extract.mdx (Italian)

* New translations toolbar.mdx (German)

* New translations file.mdx (German)

* New translations funding.mdx (German)

* New translations mapbox.mdx (German)

* New translations translation.mdx (German)

* New translations edit.mdx (German)

* New translations file.mdx (German)

* New translations settings.mdx (German)

* New translations routing.mdx (German)

* New translations time.mdx (German)

* New translations edit.mdx (German)

* New translations settings.mdx (German)

* New translations view.mdx (German)

* New translations clean.mdx (German)

* New translations extract.mdx (German)

* New translations extract.mdx (Italian)

* New translations merge.mdx (Italian)

* New translations minify.mdx (Italian)

* New translations routing.mdx (Italian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

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

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations integration.mdx (Romanian)

* New translations integration.mdx (French)

* New translations integration.mdx (Spanish)

* New translations integration.mdx (Catalan)

* New translations integration.mdx (Czech)

* New translations integration.mdx (German)

* New translations integration.mdx (Greek)

* New translations integration.mdx (Hungarian)

* New translations integration.mdx (Italian)

* New translations integration.mdx (Lithuanian)

* New translations integration.mdx (Dutch)

* New translations integration.mdx (Norwegian)

* New translations integration.mdx (Polish)

* New translations integration.mdx (Portuguese)

* New translations integration.mdx (Russian)

* New translations integration.mdx (Swedish)

* New translations integration.mdx (Chinese Simplified)

* New translations integration.mdx (Vietnamese)

* New translations integration.mdx (Portuguese, Brazilian)

* Update source file en.json

* Update source file integration.mdx

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Dutch)

* New translations en.json (Polish)

* New translations en.json (Russian)

* New translations en.json (Swedish)

* New translations en.json (Chinese Simplified)

* New translations integration.mdx (French)
2024-08-14 19:02:46 +02:00
vcoppe
84b3d29e2e embedding: add support files hosted on google drive 2024-08-14 18:27:47 +02:00
vcoppe
9327870d54 support html img in wpt desc 2024-08-14 18:19:28 +02:00
vcoppe
f36194b336 add robots.txt 2024-08-14 16:58:26 +02:00
vcoppe
f34b23253e use base in embed redirect 2024-08-14 16:35:35 +02:00
vcoppe
cfa40238e4 fix 404 imports 2024-08-14 12:46:36 +02:00
vcoppe
66b57e0013 fix 404 2024-08-14 12:14:29 +02:00
vcoppe
879b65953f backward compatibility with old embedding URLs 2024-08-14 11:29:23 +02:00
vcoppe
e800b2ebef optional parameter for language, instead of rest parameter 2024-08-14 09:27:53 +02:00
vcoppe
22e9c76a5b fix custom basemap tile URL update 2024-08-14 09:21:55 +02:00
vcoppe
d81d189cdf elevation tool test 2024-07-19 13:18:38 +02:00
547 changed files with 23111 additions and 5273 deletions

View File

@@ -36,7 +36,7 @@ jobs:
- name: Build website - name: Build website
env: env:
BASE_PATH: '/${{ github.event.repository.name }}' BASE_PATH: ''
run: | run: |
npm run build --prefix website npm run build --prefix website

View File

@@ -3,11 +3,11 @@
<img alt="Logo of gpx.studio." src="website/static/logo.svg"> <img alt="Logo of gpx.studio." src="website/static/logo.svg">
</picture> </picture>
**gpx.studio** is an online tool for creating and editing GPX files. [**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files.
![gpx.studio screenshot](website/src/lib/assets/img/docs/getting-started/interface.png) ![gpx.studio screenshot](website/src/lib/assets/img/docs/getting-started/interface.png)
This repository contains the source code of the new website, currently available [here](https://gpx.studio/gpx.studio). This repository contains the source code of the website.
## Contributing ## Contributing
@@ -72,6 +72,8 @@ This project has been made possible thanks to the following open source projects
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps - [Mapbox GL JS](https://github.com/mapbox/mapbox-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 Mapbox and brouter
- Search:
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
## License ## License

72
gpx/package-lock.json generated
View File

@@ -7,15 +7,16 @@
"": { "": {
"name": "gpx", "name": "gpx",
"version": "1.0.0", "version": "1.0.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"fast-xml-parser": "^4.4.0", "fast-xml-parser": "^4.5.0",
"immer": "^10.1.1", "immer": "^10.1.1",
"ts-node": "^10.9.2" "ts-node": "^10.9.2"
}, },
"devDependencies": { "devDependencies": {
"@types/geojson": "^7946.0.14", "@types/geojson": "^7946.0.14",
"@types/node": "^20.14.6", "@types/node": "^20.16.10",
"typescript": "^5.4.5" "typescript": "^5.6.2"
} }
}, },
"node_modules/@cspotcode/source-map-support": { "node_modules/@cspotcode/source-map-support": {
@@ -29,15 +30,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@jridgewell/resolve-uri": { "node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -47,9 +39,18 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.4.15", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
}, },
"node_modules/@tsconfig/node10": { "node_modules/@tsconfig/node10": {
"version": "1.0.11", "version": "1.0.11",
@@ -78,17 +79,17 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.14.6", "version": "20.16.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.6.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz",
"integrity": "sha512-JbA0XIJPL1IiNnU7PFxDXyfAwcwVVrOoqyzzyQTyMeVhBzkJVMSkC1LlVsRQ2lpqiY4n6Bb9oCS6lzDKVQxbZw==", "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==",
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~6.19.2"
} }
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.11.3", "version": "8.12.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -97,9 +98,12 @@
} }
}, },
"node_modules/acorn-walk": { "node_modules/acorn-walk": {
"version": "8.3.2", "version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
} }
@@ -123,9 +127,9 @@
} }
}, },
"node_modules/fast-xml-parser": { "node_modules/fast-xml-parser": {
"version": "4.4.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.0.tgz", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz",
"integrity": "sha512-kLY3jFlwIYwBNDojclKsNAC12sfD6NwW74QB2CoNGPvtVxjliYehVunB3HYyNi+n4Tt1dAcgwYvmKF/Z18flqg==", "integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -205,9 +209,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.4.5", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -217,9 +221,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "5.26.5", "version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
}, },
"node_modules/v8-compile-cache-lib": { "node_modules/v8-compile-cache-lib": {
"version": "3.0.1", "version": "3.0.1",

View File

@@ -11,16 +11,17 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"fast-xml-parser": "^4.4.0", "fast-xml-parser": "^4.5.0",
"immer": "^10.1.1", "immer": "^10.1.1",
"ts-node": "^10.9.2" "ts-node": "^10.9.2"
}, },
"scripts": {
"build": "tsc"
},
"devDependencies": { "devDependencies": {
"@types/geojson": "^7946.0.14", "@types/geojson": "^7946.0.14",
"@types/node": "^20.14.6", "@types/node": "^20.16.10",
"typescript": "^5.4.5" "typescript": "^5.6.2"
},
"scripts": {
"build": "tsc",
"postinstall": "npm run build"
} }
} }

View File

@@ -21,6 +21,7 @@ export abstract class GPXTreeElement<T extends GPXTreeElement<any>> {
abstract getEndTimestamp(): Date | undefined; abstract getEndTimestamp(): Date | undefined;
abstract getStatistics(): GPXStatistics; abstract getStatistics(): GPXStatistics;
abstract getSegments(): TrackSegment[]; abstract getSegments(): TrackSegment[];
abstract getTrackPoints(): TrackPoint[];
abstract toGeoJSON(): GeoJSON.Feature | GeoJSON.Feature[] | GeoJSON.FeatureCollection | GeoJSON.FeatureCollection[]; abstract toGeoJSON(): GeoJSON.Feature | GeoJSON.Feature[] | GeoJSON.FeatureCollection | GeoJSON.FeatureCollection[];
@@ -66,6 +67,10 @@ abstract class GPXTreeNode<T extends GPXTreeElement<any>> extends GPXTreeElement
return this.children.flatMap((child) => child.getSegments()); return this.children.flatMap((child) => child.getSegments());
} }
getTrackPoints(): TrackPoint[] {
return this.children.flatMap((child) => child.getTrackPoints());
}
// Producers // Producers
_reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date) { _reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date) {
let og = getOriginal(this); let og = getOriginal(this);
@@ -99,7 +104,7 @@ abstract class GPXTreeLeaf extends GPXTreeElement<GPXTreeLeaf> {
} }
// A class that represents a GPX file // A class that represents a GPX file
export class GPXFile extends GPXTreeNode<Track>{ export class GPXFile extends GPXTreeNode<Track> {
[immerable] = true; [immerable] = true;
attributes: GPXFileAttributes; attributes: GPXFileAttributes;
@@ -112,7 +117,15 @@ export class GPXFile extends GPXTreeNode<Track>{
super(); super();
if (gpx) { if (gpx) {
this.attributes = gpx.attributes this.attributes = gpx.attributes
this.metadata = gpx.metadata; this.metadata = gpx.metadata ?? {};
this.metadata.author = {
name: 'gpx.studio',
link: {
attributes: {
href: 'https://gpx.studio',
}
}
};
this.wpt = gpx.wpt ? gpx.wpt.map((waypoint) => new Waypoint(waypoint)) : []; this.wpt = gpx.wpt ? gpx.wpt.map((waypoint) => new Waypoint(waypoint)) : [];
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) {
@@ -350,6 +363,36 @@ export class GPXFile extends GPXTreeNode<Track>{
}); });
} }
createArtificialTimestamps(startTime: Date, totalTime: number, trackIndex?: number, segmentIndex?: number) {
let lastPoint = undefined;
this.trk.forEach((track, index) => {
if (trackIndex === undefined || trackIndex === index) {
track.createArtificialTimestamps(startTime, totalTime, lastPoint, segmentIndex);
}
});
}
addElevation(elevations: number[], trackIndices?: number[], segmentIndices?: number[], waypointIndices?: number[]) {
let index = 0;
this.trk.forEach((track, trackIndex) => {
if (trackIndices === undefined || trackIndices.includes(trackIndex)) {
track.trkseg.forEach((segment, segmentIndex) => {
if (segmentIndices === undefined || segmentIndices.includes(segmentIndex)) {
segment.trkpt.forEach((point) => {
point.ele = elevations[index++];
});
}
});
}
});
this.wpt.forEach((waypoint, waypointIndex) => {
if (waypointIndices === undefined || waypointIndices.includes(waypointIndex)) {
waypoint.ele = elevations[index++];
}
});
elevations.splice(0, index);
}
setStyle(style: LineStyleExtension) { setStyle(style: LineStyleExtension) {
this.trk.forEach((track) => { this.trk.forEach((track) => {
track.setStyle(style); track.setStyle(style);
@@ -422,8 +465,8 @@ export class Track extends GPXTreeNode<TrackSegment> {
src?: string; src?: string;
link?: Link; link?: Link;
type?: string; type?: string;
trkseg: TrackSegment[];
extensions?: TrackExtensions; extensions?: TrackExtensions;
trkseg: TrackSegment[];
constructor(track?: TrackType & { _data?: any } | Track) { constructor(track?: TrackType & { _data?: any } | Track) {
super(); super();
@@ -456,8 +499,8 @@ export class Track extends GPXTreeNode<TrackSegment> {
src: this.src, src: this.src,
link: cloneJSON(this.link), link: cloneJSON(this.link),
type: this.type, type: this.type,
trkseg: this.trkseg.map((seg) => seg.clone()),
extensions: cloneJSON(this.extensions), extensions: cloneJSON(this.extensions),
trkseg: this.trkseg.map((seg) => seg.clone()),
_data: cloneJSON(this._data), _data: cloneJSON(this._data),
}); });
} }
@@ -501,8 +544,8 @@ export class Track extends GPXTreeNode<TrackSegment> {
src: this.src, src: this.src,
link: this.link, link: this.link,
type: this.type, type: this.type,
trkseg: this.trkseg.map((seg) => seg.toTrackSegmentType(exclude)),
extensions: this.extensions, extensions: this.extensions,
trkseg: this.trkseg.map((seg) => seg.toTrackSegmentType(exclude)),
}; };
} }
@@ -581,6 +624,17 @@ export class Track extends GPXTreeNode<TrackSegment> {
}); });
} }
createArtificialTimestamps(startTime: Date, totalTime: number, lastPoint: TrackPoint | undefined, segmentIndex?: number) {
this.trkseg.forEach((segment, index) => {
if (segmentIndex === undefined || segmentIndex === index) {
segment.createArtificialTimestamps(startTime, totalTime, lastPoint);
if (segment.trkpt.length > 0) {
lastPoint = segment.trkpt[segment.trkpt.length - 1];
}
}
});
}
setStyle(style: LineStyleExtension, force: boolean = true) { setStyle(style: LineStyleExtension, force: boolean = true) {
if (!this.extensions) { if (!this.extensions) {
this.extensions = {}; this.extensions = {};
@@ -699,6 +753,11 @@ export class TrackSegment extends GPXTreeLeaf {
// extensions // extensions
if (points[i].extensions) { if (points[i].extensions) {
if (points[i].extensions["gpxtpx:TrackPointExtension"] && points[i].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"]) {
let atemp = points[i].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"];
statistics.global.atemp.avg = (statistics.global.atemp.count * statistics.global.atemp.avg + atemp) / (statistics.global.atemp.count + 1);
statistics.global.atemp.count++;
}
if (points[i].extensions["gpxtpx:TrackPointExtension"] && points[i].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"]) { if (points[i].extensions["gpxtpx:TrackPointExtension"] && points[i].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"]) {
let hr = points[i].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"]; let hr = points[i].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"];
statistics.global.hr.avg = (statistics.global.hr.count * statistics.global.hr.avg + hr) / (statistics.global.hr.count + 1); statistics.global.hr.avg = (statistics.global.hr.count * statistics.global.hr.avg + hr) / (statistics.global.hr.count + 1);
@@ -709,17 +768,20 @@ export class TrackSegment extends GPXTreeLeaf {
statistics.global.cad.avg = (statistics.global.cad.count * statistics.global.cad.avg + cad) / (statistics.global.cad.count + 1); statistics.global.cad.avg = (statistics.global.cad.count * statistics.global.cad.avg + cad) / (statistics.global.cad.count + 1);
statistics.global.cad.count++; statistics.global.cad.count++;
} }
if (points[i].extensions["gpxtpx:TrackPointExtension"] && points[i].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"]) {
let atemp = points[i].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"];
statistics.global.atemp.avg = (statistics.global.atemp.count * statistics.global.atemp.avg + atemp) / (statistics.global.atemp.count + 1);
statistics.global.atemp.count++;
}
if (points[i].extensions["gpxpx:PowerExtension"] && points[i].extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"]) { if (points[i].extensions["gpxpx:PowerExtension"] && points[i].extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"]) {
let power = points[i].extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"]; let power = points[i].extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"];
statistics.global.power.avg = (statistics.global.power.count * statistics.global.power.avg + power) / (statistics.global.power.count + 1); statistics.global.power.avg = (statistics.global.power.count * statistics.global.power.avg + power) / (statistics.global.power.count + 1);
statistics.global.power.count++; statistics.global.power.count++;
} }
} }
if (i > 0 && points[i - 1].extensions && points[i - 1].extensions["gpxtpx:TrackPointExtension"] && points[i - 1].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"] && points[i - 1].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"].surface) {
let surface = points[i - 1].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"].surface;
if (statistics.global.surface[surface] === undefined) {
statistics.global.surface[surface] = 0;
}
statistics.global.surface[surface] += dist;
}
} }
[statistics.local.slope.segment, statistics.local.slope.length] = this._computeSlopeSegments(statistics); [statistics.local.slope.segment, statistics.local.slope.length] = this._computeSlopeSegments(statistics);
@@ -753,29 +815,7 @@ export class TrackSegment extends GPXTreeLeaf {
} }
_computeSlopeSegments(statistics: GPXStatistics): [number[], number[]] { _computeSlopeSegments(statistics: GPXStatistics): [number[], number[]] {
// x-coordinates are given by: statistics.local.distance.total[point._data.index] * 1000 let simplified = ramerDouglasPeucker(this.trkpt, 20, getElevationDistanceFunction(statistics));
// y-coordinates are given by: point.ele
// Compute the distance between point3 and the line defined by point1 and point2
function elevationDistance(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint): number {
if (point1.ele === undefined || point2.ele === undefined || point3.ele === undefined) {
return 0;
}
let x1 = statistics.local.distance.total[point1._data.index] * 1000;
let x2 = statistics.local.distance.total[point2._data.index] * 1000;
let x3 = statistics.local.distance.total[point3._data.index] * 1000;
let y1 = point1.ele;
let y2 = point2.ele;
let y3 = point3.ele;
let dist = Math.sqrt(Math.pow(y2 - y1, 2) + Math.pow(x2 - x1, 2));
if (dist === 0) {
return Math.sqrt(Math.pow(x3 - x1, 2) + Math.pow(y3 - y1, 2));
}
return Math.abs((y2 - y1) * x3 - (x2 - x1) * y3 + x2 * y1 - y2 * x1) / dist;
}
let simplified = ramerDouglasPeucker(this.trkpt, 20, elevationDistance);
let slope = []; let slope = [];
let length = []; let length = [];
@@ -784,7 +824,7 @@ export class TrackSegment extends GPXTreeLeaf {
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 = statistics.local.distance.total[end] - statistics.local.distance.total[start]; let dist = statistics.local.distance.total[end] - statistics.local.distance.total[start];
let ele = simplified[i + 1].point.ele - simplified[i].point.ele; 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); slope.push(0.1 * ele / dist);
@@ -821,6 +861,10 @@ export class TrackSegment extends GPXTreeLeaf {
return [this]; return [this];
} }
getTrackPoints(): TrackPoint[] {
return this.trkpt;
}
toGeoJSON(): GeoJSON.Feature { toGeoJSON(): GeoJSON.Feature {
return { return {
type: "Feature", type: "Feature",
@@ -851,22 +895,30 @@ export class TrackSegment extends GPXTreeLeaf {
let trkpt = og.trkpt.slice(); let trkpt = og.trkpt.slice();
if (speed !== undefined || (trkpt.length > 0 && trkpt[0].time !== undefined)) { if (speed !== undefined || (trkpt.length > 0 && trkpt[0].time !== undefined)) {
// Must handle timestamps (either segment has timestamps or the new points will have timestamps)
if (start > 0 && trkpt[0].time === undefined) { if (start > 0 && trkpt[0].time === undefined) {
// Add timestamps to points before [start, end] because they are missing
trkpt.splice(0, 0, ...withTimestamps(trkpt.splice(0, start), speed, undefined, startTime)); trkpt.splice(0, 0, ...withTimestamps(trkpt.splice(0, start), speed, undefined, startTime));
} }
if (points.length > 0) { if (points.length > 0) {
// Adapt timestamps of the new points
let last = start > 0 ? trkpt[start - 1] : undefined; let last = start > 0 ? trkpt[start - 1] : undefined;
if (points[0].time === undefined || (points.length > 1 && points[1].time === undefined)) { if (points[0].time === undefined || (points.length > 1 && points[1].time === undefined)) {
// Add timestamps to the new points because they are missing
points = withTimestamps(points, speed, last, startTime); points = withTimestamps(points, speed, last, startTime);
} else if (last !== undefined && points[0].time < last.time) { } else if (last !== undefined && points[0].time < last.time) {
// Adapt timestamps of the new points because they are too early
points = withShiftedAndCompressedTimestamps(points, speed, 1, last); points = withShiftedAndCompressedTimestamps(points, speed, 1, last);
} }
} }
if (end < trkpt.length - 1) { if (end < trkpt.length - 1) {
// Adapt timestamps of points after [start, end]
let last = points.length > 0 ? points[points.length - 1] : start > 0 ? trkpt[start - 1] : undefined; let last = points.length > 0 ? points[points.length - 1] : start > 0 ? trkpt[start - 1] : undefined;
if (trkpt[end + 1].time === undefined) { if (trkpt[end + 1].time === undefined) {
// Add timestamps to points after [start, end] because they are missing
trkpt.splice(end + 1, 0, ...withTimestamps(trkpt.splice(end + 1), speed, last, startTime)); trkpt.splice(end + 1, 0, ...withTimestamps(trkpt.splice(end + 1), speed, last, startTime));
} else if (last !== undefined && trkpt[end + 1].time < last.time) { } else if (last !== undefined && trkpt[end + 1].time < last.time) {
// Adapt timestamps of points after [start, end] because they are too early
trkpt.splice(end + 1, 0, ...withShiftedAndCompressedTimestamps(trkpt.splice(end + 1), speed, 1, last)); trkpt.splice(end + 1, 0, ...withShiftedAndCompressedTimestamps(trkpt.splice(end + 1), speed, 1, last));
} }
} }
@@ -944,6 +996,14 @@ export class TrackSegment extends GPXTreeLeaf {
this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
} }
} }
createArtificialTimestamps(startTime: Date, totalTime: number, lastPoint: TrackPoint | undefined) {
let og = getOriginal(this); // Read as much as possible from the original object because it is faster
let slope = og._computeSlope();
let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, slope);
this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
}
setHidden(hidden: boolean) { setHidden(hidden: boolean) {
this._data.hidden = hidden; this._data.hidden = hidden;
} }
@@ -984,6 +1044,10 @@ export class TrackPoint {
return this.attributes.lon; return this.attributes.lon;
} }
getTemperature(): number {
return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:atemp'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:atemp'] : undefined;
}
getHeartRate(): number { getHeartRate(): number {
return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:hr'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:hr'] : undefined; return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:hr'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:hr'] : undefined;
} }
@@ -992,10 +1056,6 @@ export class TrackPoint {
return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:cad'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:cad'] : undefined; return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:cad'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:cad'] : undefined;
} }
getTemperature(): number {
return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:atemp'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:atemp'] : undefined;
}
getPower(): number { getPower(): number {
return this.extensions && this.extensions["gpxpx:PowerExtension"] && this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] ? this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] : undefined; return this.extensions && this.extensions["gpxpx:PowerExtension"] && this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] ? this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] : undefined;
} }
@@ -1032,15 +1092,15 @@ export class TrackPoint {
"gpxpx:PowerExtension": {}, "gpxpx:PowerExtension": {},
} }
}; };
if (this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"] && !exclude.includes('atemp')) {
trkpt.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"] = this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"];
}
if (this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"] && !exclude.includes('hr')) { if (this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"] && !exclude.includes('hr')) {
trkpt.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"] = this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"]; trkpt.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"] = this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"];
} }
if (this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:cad"] && !exclude.includes('cad')) { if (this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:cad"] && !exclude.includes('cad')) {
trkpt.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:cad"] = this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:cad"]; trkpt.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:cad"] = this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:cad"];
} }
if (this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"] && !exclude.includes('atemp')) {
trkpt.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"] = this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"];
}
if (this.extensions["gpxpx:PowerExtension"] && this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] && !exclude.includes('power')) { if (this.extensions["gpxpx:PowerExtension"] && this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] && !exclude.includes('power')) {
trkpt.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] = this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"]; trkpt.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] = this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"];
} }
@@ -1108,20 +1168,31 @@ export class Waypoint {
} }
toWaypointType(exclude: string[] = []): WaypointType { toWaypointType(exclude: string[] = []): WaypointType {
let wpt: WaypointType = {
attributes: this.attributes,
ele: this.ele,
name: this.name,
cmt: this.cmt,
desc: this.desc,
link: this.link,
sym: this.sym,
type: this.type,
};
if (!exclude.includes('time')) { if (!exclude.includes('time')) {
wpt = { ...wpt, time: this.time }; return {
attributes: this.attributes,
ele: this.ele,
time: this.time,
name: this.name,
cmt: this.cmt,
desc: this.desc,
link: this.link,
sym: this.sym,
type: this.type,
}
} else {
return {
attributes: this.attributes,
ele: this.ele,
name: this.name,
cmt: this.cmt,
desc: this.desc,
link: this.link,
sym: this.sym,
type: this.type,
};
} }
return wpt;
} }
clone(): Waypoint { clone(): Waypoint {
@@ -1168,6 +1239,10 @@ export class GPXStatistics {
southWest: Coordinates, southWest: Coordinates,
northEast: Coordinates, northEast: Coordinates,
}, },
atemp: {
avg: number,
count: number,
},
hr: { hr: {
avg: number, avg: number,
count: number, count: number,
@@ -1176,14 +1251,11 @@ export class GPXStatistics {
avg: number, avg: number,
count: number, count: number,
}, },
atemp: {
avg: number,
count: number,
},
power: { power: {
avg: number, avg: number,
count: number, count: number,
} },
surface: Record<string, number>,
}; };
local: { local: {
points: TrackPoint[], points: TrackPoint[],
@@ -1238,6 +1310,10 @@ export class GPXStatistics {
lon: -180, lon: -180,
}, },
}, },
atemp: {
avg: 0,
count: 0,
},
hr: { hr: {
avg: 0, avg: 0,
count: 0, count: 0,
@@ -1246,14 +1322,11 @@ export class GPXStatistics {
avg: 0, avg: 0,
count: 0, count: 0,
}, },
atemp: {
avg: 0,
count: 0,
},
power: { power: {
avg: 0, avg: 0,
count: 0, count: 0,
} },
surface: {},
}; };
this.local = { this.local = {
points: [], points: [],
@@ -1315,17 +1388,34 @@ export class GPXStatistics {
this.global.bounds.northEast.lat = Math.max(this.global.bounds.northEast.lat, other.global.bounds.northEast.lat); 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.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.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.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.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.cad.count += other.global.cad.count;
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.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.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; this.global.power.count += other.global.power.count;
Object.keys(other.global.surface).forEach((surface) => {
if (this.global.surface[surface] === undefined) {
this.global.surface[surface] = 0;
}
this.global.surface[surface] += other.global.surface[surface];
});
} }
slice(start: number, end: number): GPXStatistics { 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(); let statistics = new GPXStatistics();
statistics.local.points = this.local.points.slice(start, end + 1); statistics.local.points = this.local.points.slice(start, end + 1);
@@ -1350,9 +1440,9 @@ export class GPXStatistics {
statistics.global.bounds.northEast.lat = this.global.bounds.northEast.lat; statistics.global.bounds.northEast.lat = this.global.bounds.northEast.lat;
statistics.global.bounds.northEast.lon = this.global.bounds.northEast.lon; statistics.global.bounds.northEast.lon = this.global.bounds.northEast.lon;
statistics.global.atemp = this.global.atemp;
statistics.global.hr = this.global.hr; statistics.global.hr = this.global.hr;
statistics.global.cad = this.global.cad; statistics.global.cad = this.global.cad;
statistics.global.atemp = this.global.atemp;
statistics.global.power = this.global.power; statistics.global.power = this.global.power;
return statistics; return statistics;
@@ -1360,7 +1450,13 @@ export class GPXStatistics {
} }
const earthRadius = 6371008.8; const earthRadius = 6371008.8;
export function distance(coord1: Coordinates, coord2: Coordinates): number { export function distance(coord1: TrackPoint | Coordinates, coord2: TrackPoint | Coordinates): number {
if (coord1 instanceof TrackPoint) {
coord1 = coord1.getCoordinates();
}
if (coord2 instanceof TrackPoint) {
coord2 = coord2.getCoordinates();
}
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;
@@ -1369,6 +1465,30 @@ export function distance(coord1: Coordinates, coord2: Coordinates): number {
return maxMeters; return maxMeters;
} }
export function getElevationDistanceFunction(statistics: GPXStatistics) {
// x-coordinates are given by: statistics.local.distance.total[point._data.index] * 1000
// y-coordinates are given by: point.ele
// Compute the distance between point3 and the line defined by point1 and point2
return (point1: TrackPoint, point2: TrackPoint, point3: TrackPoint) => {
if (point1.ele === undefined || point2.ele === undefined || point3.ele === undefined) {
return 0;
}
let x1 = statistics.local.distance.total[point1._data.index] * 1000;
let x2 = statistics.local.distance.total[point2._data.index] * 1000;
let x3 = statistics.local.distance.total[point3._data.index] * 1000;
let y1 = point1.ele;
let y2 = point2.ele;
let y3 = point3.ele;
let dist = Math.sqrt(Math.pow(y2 - y1, 2) + Math.pow(x2 - x1, 2));
if (dist === 0) {
return Math.sqrt(Math.pow(x3 - x1, 2) + Math.pow(y3 - y1, 2));
}
return Math.abs((y2 - y1) * x3 - (x2 - x1) * y3 + x2 * y1 - y2 * x1) / dist;
}
}
function distanceWindowSmoothing(points: TrackPoint[], distanceWindow: number, accumulate: (index: number) => number, compute: (accumulated: number, start: number, end: number) => number, remove?: (index: number) => number): number[] { function distanceWindowSmoothing(points: TrackPoint[], distanceWindow: number, accumulate: (index: number) => number, compute: (accumulated: number, start: number, end: number) => number, remove?: (index: number) => number): number[] {
let result = []; let result = [];
@@ -1412,9 +1532,39 @@ function withTimestamps(points: TrackPoint[], speed: number, lastPoint: TrackPoi
function withShiftedAndCompressedTimestamps(points: TrackPoint[], speed: number, ratio: number, lastPoint: TrackPoint): TrackPoint[] { function withShiftedAndCompressedTimestamps(points: TrackPoint[], speed: number, ratio: number, lastPoint: TrackPoint): TrackPoint[] {
let start = getTimestamp(lastPoint, points[0], speed); let start = getTimestamp(lastPoint, points[0], speed);
let last = points[0];
return points.map((point) => { return points.map((point) => {
let pt = point.clone(); let pt = point.clone();
pt.time = new Date(start.getTime() + ratio * (point.time.getTime() - points[0].time.getTime())); if (point.time === undefined) {
pt.time = getTimestamp(last, point, speed);
} else {
pt.time = new Date(start.getTime() + ratio * (point.time.getTime() - points[0].time.getTime()));
}
last = pt;
return pt;
});
}
function withArtificialTimestamps(points: TrackPoint[], totalTime: number, lastPoint: TrackPoint | undefined, startTime: Date, slope: number[]): TrackPoint[] {
let weight = [];
let totalWeight = 0;
for (let i = 0; i < points.length - 1; i++) {
let dist = distance(points[i].getCoordinates(), points[i + 1].getCoordinates());
let w = dist * (0.5 + 1 / (1 + Math.exp(- 0.2 * slope[i])));
weight.push(w);
totalWeight += w;
}
let last = lastPoint;
return points.map((point, i) => {
let pt = point.clone();
if (i === 0) {
pt.time = lastPoint?.time ?? startTime;
} else {
pt.time = new Date(last.time.getTime() + totalTime * 1000 * weight[i - 1] / totalWeight);
}
last = pt;
return pt; return pt;
}); });
} }
@@ -1445,8 +1595,8 @@ function convertRouteToTrack(route: RouteType): Track {
src: route.src, src: route.src,
link: route.link, link: route.link,
type: route.type, type: route.type,
trkseg: [],
extensions: route.extensions, extensions: route.extensions,
trkseg: [],
}); });
if (route.rtept) { if (route.rtept) {
@@ -1462,6 +1612,8 @@ function convertRouteToTrack(route: RouteType): Track {
} else { } else {
segment.trkpt.push(new TrackPoint({ segment.trkpt.push(new TrackPoint({
attributes: rpt.attributes, attributes: rpt.attributes,
ele: rpt.ele,
time: rpt.time,
})); }));
} }
}); });
@@ -1470,4 +1622,4 @@ function convertRouteToTrack(route: RouteType): Track {
} }
return track; return track;
} }

View File

@@ -34,7 +34,7 @@ export function parseGPX(gpxData: string): GPXFile {
return new Date(tagValue); return new Date(tagValue);
} }
if (tagName === 'gpxtpx:hr' || tagName === 'gpxtpx:cad' || tagName === 'gpxtpx:atemp' || tagName === 'gpxpx:PowerInWatts' || tagName === 'opacity' || tagName === 'weight') { if (tagName === 'gpxtpx:atemp' || tagName === 'gpxtpx:hr' || tagName === 'gpxtpx:cad' || tagName === 'gpxpx:PowerInWatts' || tagName === 'opacity' || tagName === 'weight') {
return parseFloat(tagValue); return parseFloat(tagValue);
} }
@@ -61,7 +61,7 @@ export function parseGPX(gpxData: string): GPXFile {
return new GPXFile(parsed); return new GPXFile(parsed);
} }
export function buildGPX(file: GPXFile, exclude: string[]): string { export function buildGPX(file: GPXFile, exclude: string[] = []): string {
const gpx = file.toGPXFileType(exclude); const gpx = file.toGPXFileType(exclude);
const builder = new XMLBuilder({ const builder = new XMLBuilder({
@@ -87,14 +87,6 @@ export function buildGPX(file: GPXFile, exclude: string[]): string {
gpx.attributes['xmlns:gpxx'] = 'http://www.garmin.com/xmlschemas/GpxExtensions/v3'; gpx.attributes['xmlns:gpxx'] = 'http://www.garmin.com/xmlschemas/GpxExtensions/v3';
gpx.attributes['xmlns:gpxpx'] = 'http://www.garmin.com/xmlschemas/PowerExtension/v1'; gpx.attributes['xmlns:gpxpx'] = 'http://www.garmin.com/xmlschemas/PowerExtension/v1';
gpx.attributes['xmlns:gpx_style'] = 'http://www.topografix.com/GPX/gpx_style/0/2'; gpx.attributes['xmlns:gpx_style'] = 'http://www.topografix.com/GPX/gpx_style/0/2';
gpx.metadata.author = {
name: 'gpx.studio',
link: {
attributes: {
href: 'https://gpx.studio',
}
}
};
if (gpx.trk.length === 1 && (gpx.trk[0].name === undefined || gpx.trk[0].name === '')) { if (gpx.trk.length === 1 && (gpx.trk[0].name === undefined || gpx.trk[0].name === '')) {
gpx.trk[0].name = gpx.metadata.name; gpx.trk[0].name = gpx.metadata.name;
@@ -107,6 +99,20 @@ export function buildGPX(file: GPXFile, exclude: string[]): string {
encoding: "UTF-8", encoding: "UTF-8",
} }
}, },
gpx gpx: removeEmptyElements(gpx)
}); });
}
function removeEmptyElements(obj: GPXFileType): GPXFileType {
for (const key in obj) {
if (obj[key] === null || obj[key] === undefined || obj[key] === '' || (Array.isArray(obj[key]) && obj[key].length === 0)) {
delete obj[key];
} else if (typeof obj[key] === 'object' && !(obj[key] instanceof Date)) {
removeEmptyElements(obj[key]);
if (Object.keys(obj[key]).length === 0) {
delete obj[key];
}
}
}
return obj;
} }

View File

@@ -101,4 +101,55 @@ function bearing(latA: number, lonA: number, latB: number, lonB: number): number
// Finds the bearing from one lat / lon point to another. // Finds the bearing from one lat / lon point to another.
return Math.atan2(Math.sin(lonB - lonA) * Math.cos(latB), 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)); Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA));
}
export function projectedPoint(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint | Coordinates): Coordinates {
return projected(point1.getCoordinates(), point2.getCoordinates(), point3 instanceof TrackPoint ? point3.getCoordinates() : point3);
}
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
// Calculates the point on the line defined by p1 and p2
// that is closest to the third point, p3.
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
const rad = Math.PI / 180;
const lat1 = coord1.lat * rad;
const lat2 = coord2.lat * rad;
const lat3 = coord3.lat * rad;
const lon1 = coord1.lon * rad;
const lon2 = coord2.lon * rad;
const lon3 = coord3.lon * rad;
// Prerequisites for the formulas
const bear12 = bearing(lat1, lon1, lat2, lon2);
const bear13 = bearing(lat1, lon1, lat3, lon3);
let dis13 = distance(lat1, lon1, lat3, lon3);
let diff = Math.abs(bear13 - bear12);
if (diff > Math.PI) {
diff = 2 * Math.PI - diff;
}
// Is relative bearing obtuse?
if (diff > (Math.PI / 2)) {
return coord1;
}
// Find the cross-track distance.
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
// Is p4 beyond the arc?
let dis12 = distance(lat1, lon1, lat2, lon2);
let dis14 = 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 };
}
} }

View File

@@ -58,8 +58,8 @@ export type TrackType = {
src?: string; src?: string;
link?: Link; link?: Link;
type?: string; type?: string;
trkseg: TrackSegmentType[];
extensions?: TrackExtensions; extensions?: TrackExtensions;
trkseg: TrackSegmentType[];
}; };
export type TrackExtensions = { export type TrackExtensions = {
@@ -89,9 +89,9 @@ export type TrackPointExtensions = {
}; };
export type TrackPointExtension = { export type TrackPointExtension = {
'gpxtpx:atemp'?: number;
'gpxtpx:hr'?: number; 'gpxtpx:hr'?: number;
'gpxtpx:cad'?: number; 'gpxtpx:cad'?: number;
'gpxtpx:atemp'?: number;
'gpxtpx:Extensions'?: { 'gpxtpx:Extensions'?: {
surface?: string; surface?: string;
}; };

View File

@@ -0,0 +1,253 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_routes</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<rte>
<name>route 1</name>
<type>Cycling</type>
<rtept lat="50.790867" lon="4.404968">
<ele>109.0</ele>
</rtept>
<rtept lat="50.790714" lon="4.405036">
<ele>110.8</ele>
</rtept>
<rtept lat="50.790336" lon="4.405259">
<ele>110.3</ele>
</rtept>
<rtept lat="50.790165" lon="4.405331">
<ele>110.0</ele>
</rtept>
<rtept lat="50.790008" lon="4.405359">
<ele>110.3</ele>
</rtept>
<rtept lat="50.789818" lon="4.405359">
<ele>109.3</ele>
</rtept>
<rtept lat="50.789409" lon="4.40534">
<ele>107.0</ele>
</rtept>
<rtept lat="50.789105" lon="4.405411">
<ele>106.0</ele>
</rtept>
<rtept lat="50.788799" lon="4.405527">
<ele>108.5</ele>
</rtept>
<rtept lat="50.788645" lon="4.405606">
<ele>109.8</ele>
</rtept>
<rtept lat="50.7885" lon="4.405711">
<ele>110.8</ele>
</rtept>
<rtept lat="50.78822" lon="4.405959">
<ele>112.0</ele>
</rtept>
<rtept lat="50.787956" lon="4.406092">
<ele>112.8</ele>
</rtept>
<rtept lat="50.787814" lon="4.406143">
<ele>113.5</ele>
</rtept>
<rtept lat="50.787674" lon="4.406177">
<ele>114.3</ele>
</rtept>
<rtept lat="50.787451" lon="4.406199">
<ele>115.3</ele>
</rtept>
<rtept lat="50.787297" lon="4.406177">
<ele>114.8</ele>
</rtept>
<rtept lat="50.78716" lon="4.406098">
<ele>114.3</ele>
</rtept>
<rtept lat="50.787045" lon="4.405984">
<ele>114.3</ele>
</rtept>
<rtept lat="50.786683" lon="4.405653">
<ele>114.5</ele>
</rtept>
<rtept lat="50.786538" lon="4.405543">
<ele>115.0</ele>
</rtept>
<rtept lat="50.78635" lon="4.405441">
<ele>115.8</ele>
</rtept>
<rtept lat="50.786275" lon="4.40542">
<ele>115.8</ele>
</rtept>
<rtept lat="50.786182" lon="4.405435">
<ele>116.0</ele>
</rtept>
<rtept lat="50.786121" lon="4.405475">
<ele>115.8</ele>
</rtept>
<rtept lat="50.786042" lon="4.405558">
<ele>115.5</ele>
</rtept>
<rtept lat="50.785821" lon="4.405925">
<ele>114.5</ele>
</rtept>
<rtept lat="50.785672" lon="4.406119">
<ele>112.5</ele>
</rtept>
<rtept lat="50.785516" lon="4.406256">
<ele>110.8</ele>
</rtept>
<rtept lat="50.785384" lon="4.406364">
<ele>109.0</ele>
</rtept>
<rtept lat="50.785126" lon="4.406475">
<ele>106.3</ele>
</rtept>
<rtept lat="50.784697" lon="4.406537">
<ele>104.3</ele>
</rtept>
<rtept lat="50.784591" lon="4.40657">
<ele>104.0</ele>
</rtept>
<rtept lat="50.784507" lon="4.406612">
<ele>103.8</ele>
</rtept>
<rtept lat="50.784435" lon="4.40669">
<ele>103.3</ele>
</rtept>
<rtept lat="50.784209" lon="4.407148">
<ele>103.5</ele>
</rtept>
<rtept lat="50.784162" lon="4.407257">
<ele>103.8</ele>
</rtept>
<rtept lat="50.784077" lon="4.407372">
<ele>104.8</ele>
</rtept>
<rtept lat="50.784006" lon="4.407435">
<ele>105.8</ele>
</rtept>
<rtept lat="50.783924" lon="4.407471">
<ele>106.8</ele>
</rtept>
<rtept lat="50.783837" lon="4.407486">
<ele>107.8</ele>
</rtept>
<rtept lat="50.783771" lon="4.407472">
<ele>108.5</ele>
</rtept>
<rtept lat="50.783697" lon="4.407428">
<ele>109.3</ele>
</rtept>
<rtept lat="50.783626" lon="4.407363">
<ele>110.0</ele>
</rtept>
<rtept lat="50.783548" lon="4.407274">
<ele>110.5</ele>
</rtept>
<rtept lat="50.783458" lon="4.407134">
<ele>110.8</ele>
</rtept>
<rtept lat="50.783123" lon="4.406435">
<ele>111.8</ele>
</rtept>
<rtept lat="50.782982" lon="4.406168">
<ele>112.8</ele>
</rtept>
<rtept lat="50.782871" lon="4.406044">
<ele>113.3</ele>
</rtept>
</rte>
<rte>
<name>route 2</name>
<type>Cycling</type>
<rtept lat="50.782212" lon="4.406377">
<ele>115.5</ele>
</rtept>
<rtept lat="50.782175" lon="4.406413">
<ele>115.8</ele>
</rtept>
<rtept lat="50.781749" lon="4.407018">
<ele>118.5</ele>
</rtept>
<rtept lat="50.781654" lon="4.407316">
<ele>119.5</ele>
</rtept>
<rtept lat="50.781563" lon="4.407764">
<ele>121.3</ele>
</rtept>
<rtept lat="50.781487" lon="4.407984">
<ele>122.0</ele>
</rtept>
<rtept lat="50.781422" lon="4.408216">
<ele>122.8</ele>
</rtept>
<rtept lat="50.781395" lon="4.408508">
<ele>123.5</ele>
</rtept>
<rtept lat="50.781399" lon="4.409114">
<ele>126.3</ele>
</rtept>
<rtept lat="50.781367" lon="4.409428">
<ele>128.0</ele>
</rtept>
<rtept lat="50.781286" lon="4.409607">
<ele>129.0</ele>
</rtept>
<rtept lat="50.78116" lon="4.409789">
<ele>130.0</ele>
</rtept>
<rtept lat="50.780804" lon="4.409993">
<ele>130.8</ele>
</rtept>
<rtept lat="50.780389" lon="4.410334">
<ele>131.8</ele>
</rtept>
<rtept lat="50.780232" lon="4.410563">
<ele>132.3</ele>
</rtept>
<rtept lat="50.780094" lon="4.410827">
<ele>132.8</ele>
</rtept>
<rtept lat="50.779723" lon="4.411582">
<ele>135.8</ele>
</rtept>
<rtept lat="50.779591" lon="4.411791">
<ele>135.5</ele>
</rtept>
<rtept lat="50.779125" lon="4.412435">
<ele>132.5</ele>
</rtept>
<rtept lat="50.778676" lon="4.412979">
<ele>134.0</ele>
</rtept>
<rtept lat="50.778194" lon="4.413466">
<ele>136.8</ele>
</rtept>
<rtept lat="50.777427" lon="4.414302">
<ele>137.5</ele>
</rtept>
<rtept lat="50.777165" lon="4.414736">
<ele>137.3</ele>
</rtept>
<rtept lat="50.776927" lon="4.415201">
<ele>137.5</ele>
</rtept>
<rtept lat="50.776778" lon="4.415613">
<ele>137.3</ele>
</rtept>
<rtept lat="50.776553" lon="4.416425">
<ele>134.8</ele>
</rtept>
<rtept lat="50.776326" lon="4.417304">
<ele>132.3</ele>
</rtept>
<rtept lat="50.776129" lon="4.418383">
<ele>129.5</ele>
</rtept>
</rte>
</gpx>

4725
website/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,61 +13,67 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.2.2", "@sveltejs/adapter-auto": "^3.2.5",
"@sveltejs/adapter-static": "^3.0.2", "@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/enhanced-img": "^0.3.0", "@sveltejs/enhanced-img": "^0.3.8",
"@sveltejs/kit": "^2.5.17", "@sveltejs/kit": "^2.6.1",
"@sveltejs/vite-plugin-svelte": "^3.1.1", "@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/eslint": "^8.56.10", "@types/eslint": "^8.56.12",
"@types/events": "^3.0.3", "@types/events": "^3.0.3",
"@types/mapbox__mapbox-gl-geocoder": "^5.0.0", "@types/mapbox__mapbox-gl-geocoder": "^5.0.0",
"@types/mapbox-gl": "^3.1.0", "@types/mapbox__tilebelt": "^1.0.4",
"@types/node": "^20.14.6", "@types/mapbox-gl": "^3.4.0",
"@types/sanitize-html": "^2.11.0", "@types/node": "^20.16.10",
"@types/png.js": "^0.2.3",
"@types/sanitize-html": "^2.13.0",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.13.1", "@typescript-eslint/parser": "^7.18.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.20",
"eslint": "^8.57.0", "eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.40.0", "eslint-plugin-svelte": "^2.44.1",
"events": "^3.3.0", "events": "^3.3.0",
"glob": "^10.4.3", "glob": "^10.4.5",
"mdsvex": "^0.11.2", "mdsvex": "^0.11.2",
"postcss": "^8.4.38", "postcss": "^8.4.47",
"prettier": "^3.3.2", "prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.4", "prettier-plugin-svelte": "^3.2.7",
"svelte": "^4.2.18", "svelte": "^4.2.19",
"svelte-check": "^3.8.1", "svelte-check": "^3.8.6",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.13",
"tslib": "^2.6.3", "tslib": "^2.7.0",
"tsx": "^4.15.7", "tsx": "^4.19.1",
"typescript": "^5.4.5", "typescript": "^5.6.2",
"vite": "^5.3.1" "vite": "^5.4.8",
"vite-plugin-node-polyfills": "^0.22.0"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@internationalized/date": "^3.5.4", "@docsearch/js": "^3.6.2",
"@mapbox/mapbox-gl-geocoder": "^5.0.2", "@internationalized/date": "^3.5.5",
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
"@mapbox/sphericalmercator": "^1.2.0", "@mapbox/sphericalmercator": "^1.2.0",
"@mapbox/tilebelt": "^1.0.2",
"@types/mapbox__sphericalmercator": "^1.2.3", "@types/mapbox__sphericalmercator": "^1.2.3",
"bits-ui": "^0.21.12", "bits-ui": "^0.21.15",
"chart.js": "^4.4.3", "chart.js": "^4.4.4",
"chartjs-plugin-zoom": "^2.0.1", "chartjs-plugin-zoom": "^2.0.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.0.7", "dexie": "^4.0.8",
"gpx": "file:../gpx", "gpx": "file:../gpx",
"immer": "^10.1.1", "immer": "^10.1.1",
"lucide-static": "^0.427.0", "lucide-static": "^0.427.0",
"lucide-svelte": "^0.427.0", "lucide-svelte": "^0.427.0",
"mapbox-gl": "^3.4.0", "mapbox-gl": "^3.7.0",
"mapillary-js": "^4.1.2", "mapillary-js": "^4.1.2",
"mode-watcher": "^0.3.1", "mode-watcher": "^0.3.1",
"png.js": "^0.2.1",
"sanitize-html": "^2.13.0", "sanitize-html": "^2.13.0",
"sortablejs": "^1.15.2", "sortablejs": "^1.15.3",
"svelte-i18n": "^4.0.0", "svelte-i18n": "^4.0.0",
"svelte-sonner": "^0.3.24", "svelte-sonner": "^0.3.28",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.5.2",
"tailwind-variants": "^0.2.1" "tailwind-variants": "^0.2.1"
} }
} }

View File

@@ -1,10 +1,10 @@
<!doctype html> <!doctype html>
<html lang="en"> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
%sveltekit.head% %sveltekit.head%
</head> </head>

View File

@@ -8,7 +8,7 @@
--foreground: 222.2 84% 4.9%; --foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%; --muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 215.4 16.3% 45%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 222.2 84% 4.9%;
@@ -32,6 +32,8 @@
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--support: 220 15 130; --support: 220 15 130;
--link: 0 110 180;
--ring: 222.2 84% 4.9%; --ring: 222.2 84% 4.9%;
@@ -67,6 +69,8 @@
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--support: 255 110 190; --support: 255 110 190;
--link: 80 190 255;
--ring: hsl(212.7,26.8%,83.9); --ring: hsl(212.7,26.8%,83.9);
} }

View File

@@ -0,0 +1,68 @@
import { base } from '$app/paths';
import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils';
export async function handle({ event, resolve }) {
const language = event.params.language ?? 'en';
const strings = await import(`./locales/${language}.json`);
const path = event.url.pathname;
const page = event.route.id?.replace('/[[language]]', '').split('/')[1] ?? 'home';
let title = strings.metadata[`${page}_title`];
const description = strings.metadata[`description`];
if (page === 'help' && event.params.guide) {
const [guide, subguide] = event.params.guide.split('/');
const guideModule = subguide
? await import(`./lib/docs/${language}/${guide}/${subguide}.mdx`)
: await import(`./lib/docs/${language}/${guide}.mdx`);
title = `${title} | ${guideModule.metadata.title}`;
}
const htmlTag = `<html lang="${language}" translate="no">`;
let headTag = `<head>
<title>gpx.studio — ${title}</title>
<meta name="description" content="${description}" />
<meta property="og:title" content="gpx.studio — ${title}" />
<meta property="og:description" content="${description}" />
<meta name="twitter:title" content="gpx.studio — ${title}" />
<meta name="twitter:description" content="${description}" />
<meta property="og:image" content="https://gpx.studio${base}/og_logo.png" />
<meta property="og:url" content="https://gpx.studio/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="gpx.studio" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://gpx.studio${base}/og_logo.png" />
<meta name="twitter:url" content="https://gpx.studio/" />
<meta name="twitter:site" content="@gpxstudio" />
<meta name="twitter:creator" content="@gpxstudio" />
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />`;
for (let lang of Object.keys(languages)) {
headTag += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${getURLForLanguage(lang, path)}" />
`;
}
const stringsHTML = page === 'app' ? stringsToHTML(strings) : '';
const response = await resolve(event, {
transformPageChunk: ({ html }) => html.replace('<html>', htmlTag).replace('<head>', headTag).replace('</body>', `<div class="fixed -z-10 text-transparent">${stringsHTML}</div></body>`)
});
return response;
}
function stringsToHTML(dictionary, strings = new Set(), root = true) {
Object.values(dictionary).forEach((value) => {
if (typeof value === 'object') {
stringsToHTML(value, strings, false);
} else {
strings.add(value);
}
});
if (root) {
return Array.from(strings).map((string) => `<p>${string}</p>`).join('');
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -1,9 +1,9 @@
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { TramFront, Utensils, ShoppingBasket, Droplet, ShowerHead, Fuel, CircleParking, Fence, FerrisWheel, Bed, Mountain, Pickaxe, Store, TrainFront, Bus, Ship, Croissant, House, Tent, Wrench, Binoculars } from 'lucide-static'; import { TramFront, Utensils, ShoppingBasket, Droplet, ShowerHead, Fuel, CircleParking, Fence, FerrisWheel, Bed, Mountain, Pickaxe, Store, TrainFront, Bus, Ship, Croissant, House, Tent, Wrench, Binoculars } from 'lucide-static';
import { type AnySourceData, type Style } from 'mapbox-gl'; import { type Style } from 'mapbox-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';
export const basemaps: { [key: string]: string | Style; } = { export const basemaps: { [key: string]: string | Style; } = {
mapboxOutdoors: 'mapbox://styles/mapbox/outdoors-v12', mapboxOutdoors: 'mapbox://styles/mapbox/outdoors-v12',
@@ -15,7 +15,7 @@ export const basemaps: { [key: string]: string | Style; } = {
type: 'raster', type: 'raster',
tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', 'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png', 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png'], tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', 'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png', 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256, tileSize: 256,
maxzoom: 18, maxzoom: 19,
attribution: 'Map tiles by <a target="_top" rel="noopener" href="https://tile.openstreetmap.org/">OpenStreetMap tile servers</a>, under the <a target="_top" rel="noopener" href="https://operations.osmfoundation.org/policies/tiles/">tile usage policy</a>. Data by <a target="_top" rel="noopener" href="http://openstreetmap.org">OpenStreetMap</a>' attribution: 'Map tiles by <a target="_top" rel="noopener" href="https://tile.openstreetmap.org/">OpenStreetMap tile servers</a>, under the <a target="_top" rel="noopener" href="https://operations.osmfoundation.org/policies/tiles/">tile usage policy</a>. Data by <a target="_top" rel="noopener" href="http://openstreetmap.org">OpenStreetMap</a>'
} }
}, },
@@ -66,7 +66,7 @@ export const basemaps: { [key: string]: string | Style; } = {
type: 'raster', type: 'raster',
tiles: ['https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', 'https://b.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', 'https://c.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png'], tiles: ['https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', 'https://b.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', 'https://c.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png'],
tileSize: 256, tileSize: 256,
maxzoom: 17, maxzoom: 18,
attribution: '&copy; <a href="https://github.com/cyclosm/cyclosm-cartocss-style/releases" title="CyclOSM - Open Bicycle render">CyclOSM</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution: '&copy; <a href="https://github.com/cyclosm/cyclosm-cartocss-style/releases" title="CyclOSM - Open Bicycle render">CyclOSM</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
} }
}, },
@@ -167,23 +167,7 @@ export const basemaps: { [key: string]: string | Style; } = {
source: 'ignEs', source: 'ignEs',
}], }],
}, },
ordnanceSurvey: { ordnanceSurvey: "https://api.os.uk/maps/vector/v1/vts/resources/styles?srs=3857&key=piCT8WysfuC3xLSUW7sGLfrAAJoYDvQz",
version: 8,
sources: {
ordnanceSurvey: {
type: 'raster',
tiles: ['https://api.os.uk/maps/raster/v1/zxy/Outdoor_3857/{z}/{x}/{y}.png?key=piCT8WysfuC3xLSUW7sGLfrAAJoYDvQz'],
tileSize: 256,
maxzoom: 20,
attribution: '&copy; <a href="http://www.ordnancesurvey.co.uk/" target="_blank">Ordnance Survey</a>'
}
},
layers: [{
id: 'ordnanceSurvey',
type: 'raster',
source: 'ordnanceSurvey',
}],
},
norwayTopo: { norwayTopo: {
version: 8, version: 8,
sources: { sources: {
@@ -204,18 +188,49 @@ export const basemaps: { [key: string]: string | Style; } = {
swedenTopo: { swedenTopo: {
version: 8, version: 8,
sources: { sources: {
swedenTopo: { swedenTopoWMTS: {
type: 'raster', type: 'raster',
tiles: ['https://api.lantmateriet.se/open/topowebb-ccby/v1/wmts/token/1d54dd14-a28c-38a9-b6f3-b4ebfcc3c204/1.0.0/topowebb/default/3857/{z}/{y}/{x}.png'], tiles: ['https://api.lantmateriet.se/open/topowebb-ccby/v1/wmts/token/1d54dd14-a28c-38a9-b6f3-b4ebfcc3c204/1.0.0/topowebb/default/3857/{z}/{y}/{x}.png'],
tileSize: 256, tileSize: 256,
maxzoom: 14, maxzoom: 14,
attribution: '&copy; <a href="https://www.lantmateriet.se" target="_blank">Lantmäteriet</a>' attribution: '&copy; <a href="https://www.lantmateriet.se" target="_blank">Lantmäteriet</a>'
},
swedenTopoWMS: {
type: 'raster',
tiles: ['https://minkarta.lantmateriet.se/map/topowebb?REQUEST=GetMap&SERVICE=WMS&VERSION=1.1.1&FORMAT=image%2Fpng&STYLES=&TRANSPARENT=false&LAYERS=topowebbkartan&TILED=true&MAP_RESOLUTION=180&WIDTH=512&HEIGHT=512&SRS=EPSG%3A3857&BBOX={bbox-epsg-3857}'],
tileSize: 512,
minzoom: 14,
maxzoom: 20,
attribution: '&copy; <a href="https://www.lantmateriet.se" target="_blank">Lantmäteriet</a>'
} }
}, },
layers: [{ layers: [{
id: 'swedenTopo', id: 'swedenTopoWMTS',
type: 'raster', type: 'raster',
source: 'swedenTopo', source: 'swedenTopoWMTS',
maxzoom: 14
}, {
id: 'swedenTopoWMS',
type: 'raster',
source: 'swedenTopoWMS',
minzoom: 14
}],
},
swedenSatellite: {
version: 8,
sources: {
swedenSatellite: {
type: 'raster',
tiles: ['https://minkarta.lantmateriet.se/map/ortofoto?REQUEST=GetMap&SERVICE=WMS&VERSION=1.1.1&FORMAT=image%2Fpng&STYLES=&TRANSPARENT=false&LAYERS=Ortofoto_0.5%2COrtofoto_0.4%2COrtofoto_0.25%2COrtofoto_0.16&TILED=true&MAP_RESOLUTION=180&WIDTH=512&HEIGHT=512&SRS=EPSG%3A3857&BBOX={bbox-epsg-3857}'],
tileSize: 512,
maxzoom: 22,
attribution: '&copy; <a href="https://www.lantmateriet.se" target="_blank">Lantmäteriet</a>'
}
},
layers: [{
id: 'swedenSatellite',
type: 'raster',
source: 'swedenSatellite',
}], }],
}, },
finlandTopo: { finlandTopo: {
@@ -271,144 +286,309 @@ export const basemaps: { [key: string]: string | Style; } = {
}, },
}; };
export function extendBasemap(basemap: string | Style): string | Style { export const overlays: { [key: string]: string | Style; } = {
if (typeof basemap === 'object') {
basemap["glyphs"] = "mapbox://fonts/mapbox/{fontstack}/{range}.pbf";
basemap["sprite"] = `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`;
}
return basemap;
}
Object.values(basemaps).forEach(extendBasemap);
export const font: { [key: string]: string; } = {
swisstopoVector: 'Frutiger Neue Condensed Regular',
swisstopoSatellite: 'Frutiger Neue Condensed Regular',
};
export const overlays: { [key: string]: AnySourceData; } = {
cyclOSMlite: { cyclOSMlite: {
type: 'raster', version: 8,
tiles: ['https://a.tile-cyclosm.openstreetmap.fr/cyclosm-lite/{z}/{x}/{y}.png', 'https://b.tile-cyclosm.openstreetmap.fr/cyclosm-lite/{z}/{x}/{y}.png', 'https://c.tile-cyclosm.openstreetmap.fr/cyclosm-lite/{z}/{x}/{y}.png'], sources: {
tileSize: 256, cyclOSMlite: {
maxzoom: 17, type: 'raster',
attribution: '&copy; <a href="https://github.com/cyclosm/cyclosm-cartocss-style/releases" title="CyclOSM - Open Bicycle render">CyclOSM</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' tiles: ['https://a.tile-cyclosm.openstreetmap.fr/cyclosm-lite/{z}/{x}/{y}.png', 'https://b.tile-cyclosm.openstreetmap.fr/cyclosm-lite/{z}/{x}/{y}.png', 'https://c.tile-cyclosm.openstreetmap.fr/cyclosm-lite/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 17,
attribution: '&copy; <a href="https://github.com/cyclosm/cyclosm-cartocss-style/releases" title="CyclOSM - Open Bicycle render">CyclOSM</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}
},
layers: [{
id: 'cyclOSMlite',
type: 'raster',
source: 'cyclOSMlite',
}],
}, },
bikerouterGravel: bikerouterGravel,
swisstopoSlope: { swisstopoSlope: {
type: 'raster', version: 8,
tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.hangneigung-ueber_30/default/current/3857/{z}/{x}/{y}.png'], sources: {
tileSize: 256, swisstopoSlope: {
maxzoom: 17, type: 'raster',
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>', tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.hangneigung-ueber_30/default/current/3857/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 17,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>',
},
},
layers: [{
id: 'swisstopoSlope',
type: 'raster',
source: 'swisstopoSlope',
}],
}, },
swisstopoHiking: { swisstopoHiking: {
type: 'raster', version: 8,
tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swisstlm3d-wanderwege/default/current/3857/{z}/{x}/{y}.png'], sources: {
tileSize: 256, swisstopoHiking: {
maxzoom: 18, type: 'raster',
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>' tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swisstlm3d-wanderwege/default/current/3857/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
},
},
layers: [{
id: 'swisstopoHiking',
type: 'raster',
source: 'swisstopoHiking',
}],
}, },
swisstopoHikingClosures: { swisstopoHikingClosures: {
type: 'raster', version: 8,
tiles: ['https://wms.geo.admin.ch/?version=1.3.0&service=WMS&request=GetMap&sld_version=1.1.0&layers=ch.astra.wanderland-sperrungen_umleitungen&format=image/png&STYLE=default&bbox={bbox-epsg-3857}&width=256&height=256&crs=EPSG:3857&transparent=true'], sources: {
tileSize: 256, swisstopoHikingClosures: {
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>' type: 'raster',
tiles: ['https://wms.geo.admin.ch/?version=1.3.0&service=WMS&request=GetMap&sld_version=1.1.0&layers=ch.astra.wanderland-sperrungen_umleitungen&format=image/png&STYLE=default&bbox={bbox-epsg-3857}&width=256&height=256&crs=EPSG:3857&transparent=true'],
tileSize: 256,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
},
},
layers: [{
id: 'swisstopoHikingClosures',
type: 'raster',
source: 'swisstopoHikingClosures',
}],
}, },
swisstopoCycling: { swisstopoCycling: {
type: 'raster', version: 8,
tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.astra.veloland/default/current/3857/{z}/{x}/{y}.png'], sources: {
tileSize: 256, swisstopoCycling: {
maxzoom: 18, type: 'raster',
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>' tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.astra.veloland/default/current/3857/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
}
},
layers: [{
id: 'swisstopoCycling',
type: 'raster',
source: 'swisstopoCycling',
}],
}, },
swisstopoCyclingClosures: { swisstopoCyclingClosures: {
type: 'raster', version: 8,
tiles: ['https://wms.geo.admin.ch/?version=1.3.0&service=WMS&request=GetMap&sld_version=1.1.0&layers=ch.astra.veloland-sperrungen_umleitungen&format=image/png&STYLE=default&bbox={bbox-epsg-3857}&width=256&height=256&crs=EPSG:3857&transparent=true'], sources: {
tileSize: 256, swisstopoCyclingClosures: {
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>' type: 'raster',
tiles: ['https://wms.geo.admin.ch/?version=1.3.0&service=WMS&request=GetMap&sld_version=1.1.0&layers=ch.astra.veloland-sperrungen_umleitungen&format=image/png&STYLE=default&bbox={bbox-epsg-3857}&width=256&height=256&crs=EPSG:3857&transparent=true'],
tileSize: 256,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
}
},
layers: [{
id: 'swisstopoCyclingClosures',
type: 'raster',
source: 'swisstopoCyclingClosures',
}],
}, },
swisstopoMountainBike: { swisstopoMountainBike: {
type: 'raster', version: 8,
tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.astra.mountainbikeland/default/current/3857/{z}/{x}/{y}.png'], sources: {
tileSize: 256, swisstopoMountainBike: {
maxzoom: 18, type: 'raster',
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>' tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.astra.mountainbikeland/default/current/3857/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
}
},
layers: [{
id: 'swisstopoMountainBike',
type: 'raster',
source: 'swisstopoMountainBike',
}],
}, },
swisstopoMountainBikeClosures: { swisstopoMountainBikeClosures: {
type: 'raster', version: 8,
tiles: ['https://wms.geo.admin.ch/?version=1.3.0&service=WMS&request=GetMap&sld_version=1.1.0&layers=ch.astra.mountainbikeland-sperrungen_umleitungen&format=image/png&STYLE=default&bbox={bbox-epsg-3857}&width=256&height=256&crs=EPSG:3857&transparent=true'], sources: {
tileSize: 256, swisstopoMountainBikeClosures: {
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>' type: 'raster',
tiles: ['https://wms.geo.admin.ch/?version=1.3.0&service=WMS&request=GetMap&sld_version=1.1.0&layers=ch.astra.mountainbikeland-sperrungen_umleitungen&format=image/png&STYLE=default&bbox={bbox-epsg-3857}&width=256&height=256&crs=EPSG:3857&transparent=true'],
tileSize: 256,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
}
},
layers: [{
id: 'swisstopoMountainBikeClosures',
type: 'raster',
source: 'swisstopoMountainBikeClosures',
}],
}, },
swisstopoSkiTouring: { swisstopoSkiTouring: {
type: 'raster', version: 8,
tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.swisstopo-karto.skitouren/default/current/3857/{z}/{x}/{y}.png'], sources: {
tileSize: 256, swisstopoSkiTouring: {
maxzoom: 17, type: 'raster',
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>' tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.swisstopo-karto.skitouren/default/current/3857/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 17,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
}
},
layers: [{
id: 'swisstopoSkiTouring',
type: 'raster',
source: 'swisstopoSkiTouring',
}],
}, },
ignFrCadastre: { ignFrCadastre: {
type: 'raster', version: 8,
tiles: ['https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile&TILEMATRIXSET=PM&TILEMATRIX={z}&TILECOL={x}&TILEROW={y}&LAYER=CADASTRALPARCELS.PARCELS&FORMAT=image/png&STYLE=normal'], sources: {
tileSize: 256, ignFrCadastre: {
maxzoom: 20, type: 'raster',
attribution: 'IGN-F/Géoportail' tiles: ['https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile&TILEMATRIXSET=PM&TILEMATRIX={z}&TILECOL={x}&TILEROW={y}&LAYER=CADASTRALPARCELS.PARCELS&FORMAT=image/png&STYLE=normal'],
tileSize: 256,
maxzoom: 20,
attribution: 'IGN-F/Géoportail'
}
},
layers: [{
id: 'ignFrCadastre',
type: 'raster',
source: 'ignFrCadastre',
}],
}, },
ignSlope: { ignSlope: {
type: 'raster', version: 8,
tiles: ['https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile&TileMatrixSet=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&Layer=GEOGRAPHICALGRIDSYSTEMS.SLOPES.MOUNTAIN&FORMAT=image/png&Style=normal'], sources: {
tileSize: 256, ignSlope: {
maxzoom: 17, type: 'raster',
attribution: 'IGN-F/Géoportail' tiles: ['https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile&TileMatrixSet=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&Layer=GEOGRAPHICALGRIDSYSTEMS.SLOPES.MOUNTAIN&FORMAT=image/png&Style=normal'],
tileSize: 256,
attribution: 'IGN-F/Géoportail'
}
},
layers: [{
id: 'ignSlope',
type: 'raster',
source: 'ignSlope',
}],
}, },
ignSkiTouring: { ignSkiTouring: {
type: 'raster', version: 8,
tiles: ['https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile&TileMatrixSet=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&Layer=TRACES.RANDO.HIVERNALE&FORMAT=image/png&Style=normal'], sources: {
tileSize: 256, ignSkiTouring: {
maxzoom: 16, type: 'raster',
attribution: 'IGN-F/Géoportail' tiles: ['https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile&TileMatrixSet=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&Layer=TRACES.RANDO.HIVERNALE&FORMAT=image/png&Style=normal'],
tileSize: 256,
maxzoom: 16,
attribution: 'IGN-F/Géoportail'
},
},
layers: [{
id: 'ignSkiTouring',
type: 'raster',
source: 'ignSkiTouring',
}],
}, },
waymarkedTrailsHiking: { waymarkedTrailsHiking: {
type: 'raster', version: 8,
tiles: ['https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png'], sources: {
tileSize: 256, waymarkedTrailsHiking: {
maxzoom: 18, type: 'raster',
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>' tiles: ['https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
}
},
layers: [{
id: 'waymarkedTrailsHiking',
type: 'raster',
source: 'waymarkedTrailsHiking',
}],
}, },
waymarkedTrailsCycling: { waymarkedTrailsCycling: {
type: 'raster', version: 8,
tiles: ['https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png'], sources: {
tileSize: 256, waymarkedTrailsCycling: {
maxzoom: 18, type: 'raster',
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>' tiles: ['https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
}
},
layers: [{
id: 'waymarkedTrailsCycling',
type: 'raster',
source: 'waymarkedTrailsCycling',
}],
}, },
waymarkedTrailsMTB: { waymarkedTrailsMTB: {
type: 'raster', version: 8,
tiles: ['https://tile.waymarkedtrails.org/mtb/{z}/{x}/{y}.png'], sources: {
tileSize: 256, waymarkedTrailsMTB: {
maxzoom: 18, type: 'raster',
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>' tiles: ['https://tile.waymarkedtrails.org/mtb/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
}
},
layers: [{
id: 'waymarkedTrailsMTB',
type: 'raster',
source: 'waymarkedTrailsMTB',
}],
}, },
waymarkedTrailsSkating: { waymarkedTrailsSkating: {
type: 'raster', version: 8,
tiles: ['https://tile.waymarkedtrails.org/skating/{z}/{x}/{y}.png'], sources: {
tileSize: 256, waymarkedTrailsSkating: {
maxzoom: 18, type: 'raster',
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>' tiles: ['https://tile.waymarkedtrails.org/skating/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
}
},
layers: [{
id: 'waymarkedTrailsSkating',
type: 'raster',
source: 'waymarkedTrailsSkating',
}],
}, },
waymarkedTrailsHorseRiding: { waymarkedTrailsHorseRiding: {
type: 'raster', version: 8,
tiles: ['https://tile.waymarkedtrails.org/riding/{z}/{x}/{y}.png'], sources: {
tileSize: 256, waymarkedTrailsHorseRiding: {
maxzoom: 18, type: 'raster',
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>' tiles: ['https://tile.waymarkedtrails.org/riding/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
}
},
layers: [{
id: 'waymarkedTrailsHorseRiding',
type: 'raster',
source: 'waymarkedTrailsHorseRiding',
}],
}, },
waymarkedTrailsWinter: { waymarkedTrailsWinter: {
type: 'raster', version: 8,
tiles: ['https://tile.waymarkedtrails.org/slopes/{z}/{x}/{y}.png'], sources: {
tileSize: 256, waymarkedTrailsWinter: {
maxzoom: 18, type: 'raster',
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>' tiles: ['https://tile.waymarkedtrails.org/slopes/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
}
},
layers: [{
id: 'waymarkedTrailsWinter',
type: 'raster',
source: 'waymarkedTrailsWinter',
}],
}, },
}; };
@@ -459,6 +639,7 @@ export const basemapTree: LayerTreeType = {
}, },
sweden: { sweden: {
swedenTopo: true, swedenTopo: true,
swedenSatellite: true,
}, },
switzerland: { switzerland: {
swisstopoRaster: true, swisstopoRaster: true,
@@ -479,9 +660,6 @@ export const basemapTree: LayerTreeType = {
export const overlayTree: LayerTreeType = { export const overlayTree: LayerTreeType = {
overlays: { overlays: {
world: { world: {
cyclOSM: {
cyclOSMlite: true,
},
waymarked_trails: { waymarked_trails: {
waymarkedTrailsHiking: true, waymarkedTrailsHiking: true,
waymarkedTrailsCycling: true, waymarkedTrailsCycling: true,
@@ -489,7 +667,9 @@ export const overlayTree: LayerTreeType = {
waymarkedTrailsSkating: true, waymarkedTrailsSkating: true,
waymarkedTrailsHorseRiding: true, waymarkedTrailsHorseRiding: true,
waymarkedTrailsWinter: true, waymarkedTrailsWinter: true,
} },
cyclOSMlite: true,
bikerouterGravel: true,
}, },
countries: { countries: {
france: { france: {
@@ -563,9 +743,6 @@ export const defaultBasemap = 'mapboxOutdoors';
export const defaultOverlays = { export const defaultOverlays = {
overlays: { overlays: {
world: { world: {
cyclOSM: {
cyclOSMlite: false,
},
waymarked_trails: { waymarked_trails: {
waymarkedTrailsHiking: false, waymarkedTrailsHiking: false,
waymarkedTrailsCycling: false, waymarkedTrailsCycling: false,
@@ -573,7 +750,9 @@ export const defaultOverlays = {
waymarkedTrailsSkating: false, waymarkedTrailsSkating: false,
waymarkedTrailsHorseRiding: false, waymarkedTrailsHorseRiding: false,
waymarkedTrailsWinter: false, waymarkedTrailsWinter: false,
} },
cyclOSMlite: false,
bikerouterGravel: false,
}, },
countries: { countries: {
france: { france: {
@@ -679,6 +858,7 @@ export const defaultBasemapTree: LayerTreeType = {
}, },
sweden: { sweden: {
swedenTopo: false, swedenTopo: false,
swedenSatellite: false,
}, },
switzerland: { switzerland: {
swisstopoRaster: false, swisstopoRaster: false,
@@ -699,9 +879,6 @@ export const defaultBasemapTree: LayerTreeType = {
export const defaultOverlayTree: LayerTreeType = { export const defaultOverlayTree: LayerTreeType = {
overlays: { overlays: {
world: { world: {
cyclOSM: {
cyclOSMlite: false,
},
waymarked_trails: { waymarked_trails: {
waymarkedTrailsHiking: true, waymarkedTrailsHiking: true,
waymarkedTrailsCycling: true, waymarkedTrailsCycling: true,
@@ -709,7 +886,9 @@ export const defaultOverlayTree: LayerTreeType = {
waymarkedTrailsSkating: false, waymarkedTrailsSkating: false,
waymarkedTrailsHorseRiding: false, waymarkedTrailsHorseRiding: false,
waymarkedTrailsWinter: false, waymarkedTrailsWinter: false,
} },
cyclOSMlite: false,
bikerouterGravel: false,
}, },
countries: { countries: {
france: { france: {

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import docsearch from '@docsearch/js';
import '@docsearch/css';
import { onMount } from 'svelte';
import { _, locale, waitLocale } from 'svelte-i18n';
let mounted = false;
function initDocsearch() {
docsearch({
appId: '21XLD94PE3',
apiKey: 'd2c1ed6cb0ed12adb2bd84eb2a38494d',
indexName: 'gpx',
container: '#docsearch',
searchParameters: {
facetFilters: ['lang:' + ($locale ?? 'en')]
},
placeholder: $_('docs.search.search'),
disableUserPersonalization: true,
translations: {
button: {
buttonText: $_('docs.search.search'),
buttonAriaLabel: $_('docs.search.search')
},
modal: {
searchBox: {
resetButtonTitle: $_('docs.search.clear'),
resetButtonAriaLabel: $_('docs.search.clear'),
cancelButtonText: $_('docs.search.cancel'),
cancelButtonAriaLabel: $_('docs.search.cancel'),
searchInputLabel: $_('docs.search.search')
},
footer: {
selectText: $_('docs.search.to_select'),
navigateText: $_('docs.search.to_navigate'),
closeText: $_('docs.search.to_close')
},
noResultsScreen: {
noResultsText: $_('docs.search.no_results'),
suggestedQueryText: $_('docs.search.no_results_suggestion')
}
}
}
});
}
onMount(() => {
mounted = true;
});
$: if (mounted && $locale) {
waitLocale().then(initDocsearch);
}
</script>
<svelte:head>
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
</svelte:head>
<div id="docsearch" {...$$restProps}></div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
export let variant:
| 'default'
| 'secondary'
| 'link'
| 'destructive'
| 'outline'
| 'ghost'
| undefined = 'default';
export let label: string;
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
</script>
<Tooltip.Root>
<Tooltip.Trigger asChild let:builder>
<Button builders={[builder]} {variant} {...$$restProps}>
<slot />
</Button>
</Tooltip.Trigger>
<Tooltip.Content {side}>
<span>{label}</span>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -104,7 +104,8 @@
line: { line: {
pointRadius: 0, pointRadius: 0,
tension: 0.4, tension: 0.4,
borderWidth: 2 borderWidth: 2,
cubicInterpolationMode: 'monotone'
} }
}, },
interaction: { interaction: {
@@ -624,16 +625,14 @@
type="single" type="single"
bind:value={elevationFill} bind:value={elevationFill}
> >
<ToggleGroup.Item class="p-0 w-5 h-5" value="slope"> <ToggleGroup.Item class="p-0 w-5 h-5" value="slope" aria-label={$_('chart.show_slope')}>
<Tooltip side="left"> <Tooltip side="left" label={$_('chart.show_slope')}>
<TriangleRight slot="data" size="15" /> <TriangleRight size="15" />
<span slot="tooltip">{$_('chart.show_slope')}</span>
</Tooltip> </Tooltip>
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-5 h-5" value="surface"> <ToggleGroup.Item class="p-0 w-5 h-5" value="surface" aria-label={$_('chart.show_surface')}>
<Tooltip side="left"> <Tooltip side="left" label={$_('chart.show_surface')}>
<BrickWall slot="data" size="15" /> <BrickWall size="15" />
<span slot="tooltip">{$_('chart.show_surface')}</span>
</Tooltip> </Tooltip>
</ToggleGroup.Item> </ToggleGroup.Item>
</ToggleGroup.Root> </ToggleGroup.Root>
@@ -644,36 +643,40 @@
type="multiple" type="multiple"
bind:value={additionalDatasets} bind:value={additionalDatasets}
> >
<ToggleGroup.Item class="p-0 w-5 h-5" value="speed"> <ToggleGroup.Item
<Tooltip side="left"> class="p-0 w-5 h-5"
<Zap slot="data" size="15" /> value="speed"
<span slot="tooltip" aria-label={$velocityUnits === 'speed' ? $_('chart.show_speed') : $_('chart.show_pace')}
>{$velocityUnits === 'speed' ? $_('chart.show_speed') : $_('chart.show_pace')}</span >
> <Tooltip
side="left"
label={$velocityUnits === 'speed' ? $_('chart.show_speed') : $_('chart.show_pace')}
>
<Zap size="15" />
</Tooltip> </Tooltip>
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-5 h-5" value="hr"> <ToggleGroup.Item class="p-0 w-5 h-5" value="hr" aria-label={$_('chart.show_heartrate')}>
<Tooltip side="left"> <Tooltip side="left" label={$_('chart.show_heartrate')}>
<HeartPulse slot="data" size="15" /> <HeartPulse size="15" />
<span slot="tooltip">{$_('chart.show_heartrate')}</span>
</Tooltip> </Tooltip>
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-5 h-5" value="cad"> <ToggleGroup.Item class="p-0 w-5 h-5" value="cad" aria-label={$_('chart.show_cadence')}>
<Tooltip side="left"> <Tooltip side="left" label={$_('chart.show_cadence')}>
<Orbit slot="data" size="15" /> <Orbit size="15" />
<span slot="tooltip">{$_('chart.show_cadence')}</span>
</Tooltip> </Tooltip>
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-5 h-5" value="atemp"> <ToggleGroup.Item
<Tooltip side="left"> class="p-0 w-5 h-5"
<Thermometer slot="data" size="15" /> value="atemp"
<span slot="tooltip">{$_('chart.show_temperature')}</span> aria-label={$_('chart.show_temperature')}
>
<Tooltip side="left" label={$_('chart.show_temperature')}>
<Thermometer size="15" />
</Tooltip> </Tooltip>
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-5 h-5" value="power"> <ToggleGroup.Item class="p-0 w-5 h-5" value="power" aria-label={$_('chart.show_power')}>
<Tooltip side="left"> <Tooltip side="left" label={$_('chart.show_power')}>
<SquareActivity slot="data" size="15" /> <SquareActivity size="15" />
<span slot="tooltip">{$_('chart.show_power')}</span>
</Tooltip> </Tooltip>
</ToggleGroup.Item> </ToggleGroup.Item>
</ToggleGroup.Root> </ToggleGroup.Root>

View File

@@ -63,6 +63,7 @@
} }
hide.time = statistics.global.time.total === 0; hide.time = statistics.global.time.total === 0;
hide.surface = !Object.keys(statistics.global.surface).some((key) => key !== 'unknown');
hide.hr = statistics.global.hr.count === 0; hide.hr = statistics.global.hr.count === 0;
hide.cad = statistics.global.cad.count === 0; hide.cad = statistics.global.cad.count === 0;
hide.atemp = statistics.global.atemp.count === 0; hide.atemp = statistics.global.atemp.count === 0;
@@ -86,10 +87,10 @@
class="fixed left-[50%] top-[50%] z-50 w-fit max-w-full translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-3 border bg-background p-3 shadow-lg rounded-md" class="fixed left-[50%] top-[50%] z-50 w-fit max-w-full translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-3 border bg-background p-3 shadow-lg rounded-md"
> >
<div <div
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-accent" class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-secondary"
> >
<span>⚠️</span> <span>⚠️</span>
<span class="max-w-96 text-sm"> <span class="max-w-[80%] text-sm">
{$_('menu.support_message')} {$_('menu.support_message')}
</span> </span>
</div> </div>
@@ -119,7 +120,11 @@
{/if} {/if}
</Button> </Button>
</div> </div>
<div class="w-full max-w-xl flex flex-col items-center gap-2"> <div
class="w-full max-w-xl flex flex-col items-center gap-2 {Object.values(hide).some((v) => !v)
? ''
: 'hidden'}"
>
<div class="w-full flex flex-row items-center gap-3"> <div class="w-full flex flex-row items-center gap-3">
<div class="grow"> <div class="grow">
<Separator /> <Separator />
@@ -139,7 +144,7 @@
{$_('quantities.time')} {$_('quantities.time')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-1.5"> <div class="flex flex-row items-center gap-1.5 {hide.surface ? 'hidden' : ''}">
<Checkbox id="export-surface" bind:checked={exportOptions.surface} /> <Checkbox id="export-surface" bind:checked={exportOptions.surface} />
<Label for="export-surface" class="flex flex-row items-center gap-1"> <Label for="export-surface" class="flex flex-row items-center gap-1">
<BrickWall size="16" /> <BrickWall size="16" />

View File

@@ -11,7 +11,7 @@
<div class="mx-6 border-t"> <div class="mx-6 border-t">
<div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6"> <div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="grow flex flex-col items-start"> <div class="grow flex flex-col items-start">
<Logo class="h-8" /> <Logo class="h-8" width="153" />
<Button <Button
variant="link" variant="link"
class="h-6 px-0 text-muted-foreground" class="h-6 px-0 text-muted-foreground"
@@ -52,6 +52,15 @@
</div> </div>
<div class="flex flex-col items-start gap-1" id="contact"> <div class="flex flex-col items-start gap-1" id="contact">
<span class="font-semibold">{$_('homepage.contact')}</span> <span class="font-semibold">{$_('homepage.contact')}</span>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://www.reddit.com/r/gpxstudio/"
target="_blank"
>
<Logo company="reddit" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.reddit')}
</Button>
<Button <Button
variant="link" variant="link"
class="h-6 px-0 text-muted-foreground" class="h-6 px-0 text-muted-foreground"

View File

@@ -36,48 +36,46 @@
? 'flex-col justify-center' ? 'flex-col justify-center'
: 'flex-row w-full justify-between'} gap-4 p-0" : 'flex-row w-full justify-between'} gap-4 p-0"
> >
<Tooltip> <Tooltip label={$_('quantities.distance')}>
<span slot="data" class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Ruler size="18" class="mr-1" /> <Ruler size="18" class="mr-1" />
<WithUnits value={statistics.global.distance.total} type="distance" /> <WithUnits value={statistics.global.distance.total} type="distance" />
</span> </span>
<span slot="tooltip">{$_('quantities.distance')}</span>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip label={$_('quantities.elevation_gain_loss')}>
<span slot="data" class="flex flex-row items-center"> <span class="flex flex-row items-center">
<MoveUpRight size="18" class="mr-1" /> <MoveUpRight size="18" class="mr-1" />
<WithUnits value={statistics.global.elevation.gain} type="elevation" /> <WithUnits value={statistics.global.elevation.gain} type="elevation" />
<MoveDownRight size="18" class="mx-1" /> <MoveDownRight size="18" class="mx-1" />
<WithUnits value={statistics.global.elevation.loss} type="elevation" /> <WithUnits value={statistics.global.elevation.loss} type="elevation" />
</span> </span>
<span slot="tooltip">{$_('quantities.elevation')}</span>
</Tooltip> </Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'} {#if panelSize > 120 || orientation === 'horizontal'}
<Tooltip class={orientation === 'horizontal' ? 'hidden xs:block' : ''}> <Tooltip
<span slot="data" class="flex flex-row items-center"> class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
label="{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_(
'quantities.moving'
)} / {$_('quantities.total')})"
>
<span class="flex flex-row items-center">
<Zap size="18" class="mr-1" /> <Zap size="18" class="mr-1" />
<WithUnits value={statistics.global.speed.moving} type="speed" showUnits={false} /> <WithUnits 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.global.speed.total} type="speed" />
</span> </span>
<span slot="tooltip"
>{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_(
'quantities.moving'
)} / {$_('quantities.total')})</span
>
</Tooltip> </Tooltip>
{/if} {/if}
{#if panelSize > 160 || orientation === 'horizontal'} {#if panelSize > 160 || orientation === 'horizontal'}
<Tooltip class={orientation === 'horizontal' ? 'hidden md:block' : ''}> <Tooltip
<span slot="data" class="flex flex-row items-center"> class={orientation === 'horizontal' ? 'hidden md:block' : ''}
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_('quantities.total')})"
>
<span class="flex flex-row items-center">
<Timer size="18" class="mr-1" /> <Timer size="18" class="mr-1" />
<WithUnits value={statistics.global.time.moving} type="time" /> <WithUnits value={statistics.global.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.global.time.total} type="time" />
</span> </span>
<span slot="tooltip"
>{$_('quantities.time')} ({$_('quantities.moving')} / {$_('quantities.total')})</span
>
</Tooltip> </Tooltip>
{/if} {/if}
</Card.Content> </Card.Content>

View File

@@ -1,70 +0,0 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { languages } from '$lib/languages';
import { _, isLoading } from 'svelte-i18n';
let location: string;
let title: string;
$: if ($page.route.id) {
location = $page.route.id;
Object.keys($page.params).forEach((param) => {
if (param !== 'language') {
location = location.replace(`[${param}]`, $page.params[param]);
location = location.replace(`[...${param}]`, $page.params[param]);
}
});
title = location.replace('/[...language]', '').split('/')[1] ?? 'home';
}
</script>
<svelte:head>
{#if $isLoading}
<title>gpx.studio — the online GPX file editor</title>
<meta
name="description"
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
/>
<meta property="og:title" content="gpx.studio — the online GPX file editor" />
<meta
property="og:description"
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
/>
<meta name="twitter:title" content="gpx.studio — the online GPX file editor" />
<meta
name="twitter:description"
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
/>
{:else}
<title>gpx.studio — {$_(`metadata.${title}_title`)}</title>
<meta name="description" content={$_('metadata.description')} />
<meta property="og:title" content="gpx.studio — {$_(`metadata.${title}_title`)}" />
<meta property="og:description" content={$_('metadata.description')} />
<meta name="twitter:title" content="gpx.studio — {$_(`metadata.${title}_title`)}" />
<meta name="twitter:description" content={$_('metadata.description')} />
{/if}
<meta property="og:image" content="https://gpx.studio/og_logo.png" />
<meta property="og:url" content="https://gpx.studio/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="gpx.studio" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://gpx.studio/og_logo.png" />
<meta name="twitter:url" content="https://gpx.studio/" />
<meta name="twitter:site" content="@gpxstudio" />
<meta name="twitter:creator" content="@gpxstudio" />
<link
rel="alternate"
hreflang="x-default"
href="https://gpx.studio{base}{location.replace('/[...language]', '')}"
/>
{#each Object.keys(languages) as lang}
<link
rel="alternate"
hreflang={lang}
href="https://gpx.studio{base}{location.replace('[...language]', lang)}"
/>
{/each}
</svelte:head>

View File

@@ -5,16 +5,14 @@
export let link: string | undefined = undefined; export let link: string | undefined = undefined;
</script> </script>
<div class="text-sm bg-muted rounded border flex flex-row items-center p-2 {$$props.class || ''}"> <div
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
>
<CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" /> <CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" />
<div> <div>
<slot /> <slot />
{#if link} {#if link}
<a <a href={link} target="_blank" class="text-sm text-link hover:underline">
href={link}
target="_blank"
class="text-sm text-blue-500 dark:text-blue-300 hover:underline"
>
{$_('menu.more')} {$_('menu.more')}
</a> </a>
{/if} {/if}

View File

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

View File

@@ -60,4 +60,14 @@
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" 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 /></svg
> >
{:else if company === 'reddit'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><title>Reddit</title><path
d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"
/></svg
>
{/if} {/if}

View File

@@ -52,7 +52,37 @@
let newMap = new mapboxgl.Map({ let newMap = new mapboxgl.Map({
container: 'map', container: 'map',
style: { version: 8, sources: {}, layers: [] }, style: {
version: 8,
sources: {},
layers: [],
imports: [
{
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
url: '',
data: {
version: 8,
sources: {},
layers: [],
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`
}
},
{
id: 'basemap',
url: ''
},
{
id: 'overlays',
url: '',
data: {
version: 8,
sources: {},
layers: []
}
}
]
},
zoom: 0, zoom: 0,
hash: hash, hash: hash,
language, language,
@@ -62,6 +92,7 @@
}); });
newMap.on('load', () => { newMap.on('load', () => {
$map = newMap; // only set the store after the map has loaded $map = newMap; // only set the store after the map has loaded
window._map = newMap; // entry point for extensions
scaleControl.setUnit($distanceUnits); scaleControl.setUnit($distanceUnits);
}); });
@@ -78,15 +109,42 @@
); );
if (geocoder) { if (geocoder) {
newMap.addControl( let geocoder = new MapboxGeocoder({
new MapboxGeocoder({ mapboxgl: mapboxgl,
accessToken: mapboxgl.accessToken, enableEventLogging: false,
mapboxgl: mapboxgl, collapsed: true,
collapsed: true, flyTo: fitBoundsOptions,
flyTo: fitBoundsOptions, language,
language localGeocoder: () => [],
}) localGeocoderOnly: true,
); externalGeocoder: (query: string) =>
fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
)
.then((response) => response.json())
.then((data) => {
return data.map((result: any) => {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [result.lon, result.lat]
},
place_name: result.display_name
};
});
})
});
let onKeyDown = geocoder._onKeyDown;
geocoder._onKeyDown = (e: KeyboardEvent) => {
// Trigger search on Enter key only
if (e.key === 'Enter') {
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
} else if (geocoder._typeahead.data.length > 0) {
geocoder._typeahead.clear();
}
};
newMap.addControl(geocoder);
} }
if (geolocate) { if (geolocate) {
@@ -111,10 +169,12 @@
tileSize: 512, tileSize: 512,
maxzoom: 14 maxzoom: 14
}); });
newMap.setTerrain({ if (newMap.getPitch() > 0) {
source: 'mapbox-dem', newMap.setTerrain({
exaggeration: newMap.getPitch() > 0 ? 1 : 0 source: 'mapbox-dem',
}); exaggeration: 1
});
}
newMap.setFog({ newMap.setFog({
color: 'rgb(186, 210, 235)', color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)', 'high-color': 'rgb(36, 92, 223)',
@@ -128,18 +188,7 @@
exaggeration: 1 exaggeration: 1
}); });
} else { } else {
newMap.setTerrain({ newMap.setTerrain(null);
source: 'mapbox-dem',
exaggeration: 0
});
}
});
// add dummy layer to place the overlay layers below
newMap.addLayer({
id: 'overlays',
type: 'background',
paint: {
'background-color': 'rgba(0, 0, 0, 0)'
} }
}); });
}); });

View File

@@ -21,7 +21,7 @@
Thermometer, Thermometer,
Sun, Sun,
Moon, Moon,
Layers3, Layers,
GalleryVertical, GalleryVertical,
Languages, Languages,
Settings, Settings,
@@ -41,7 +41,8 @@
FileStack, FileStack,
FileX, FileX,
BookOpenText, BookOpenText,
ChartArea ChartArea,
Maximize
} from 'lucide-svelte'; } from 'lucide-svelte';
import { import {
@@ -54,7 +55,8 @@
editMetadata, editMetadata,
editStyle, editStyle,
exportState, exportState,
ExportState ExportState,
centerMapOnSelection
} from '$lib/stores'; } from '$lib/stores';
import { import {
copied, copied,
@@ -126,13 +128,13 @@
<div <div
class="w-fit flex flex-row items-center justify-center p-1 bg-background rounded-b-md md:rounded-md pointer-events-auto shadow-md" class="w-fit flex flex-row items-center justify-center p-1 bg-background rounded-b-md md:rounded-md pointer-events-auto shadow-md"
> >
<a href="./" target="_blank"> <a href="./" target="_blank" class="shrink-0">
<Logo class="h-5 mt-0.5 mx-2 md:hidden" iconOnly={true} /> <Logo class="h-5 mt-0.5 mx-2 md:hidden" iconOnly={true} width="16" />
<Logo class="h-5 mt-0.5 mx-2 hidden md:block" /> <Logo class="h-5 mt-0.5 mx-2 hidden md:block" width="96" />
</a> </a>
<Menubar.Root class="border-none h-fit p-0"> <Menubar.Root class="border-none h-fit p-0">
<Menubar.Menu> <Menubar.Menu>
<Menubar.Trigger> <Menubar.Trigger aria-label={$_('gpx.file')}>
<File size="18" class="md:hidden" /> <File size="18" class="md:hidden" />
<span class="hidden md:block">{$_('gpx.file')}</span> <span class="hidden md:block">{$_('gpx.file')}</span>
</Menubar.Trigger> </Menubar.Trigger>
@@ -185,7 +187,7 @@
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
<Menubar.Menu> <Menubar.Menu>
<Menubar.Trigger> <Menubar.Trigger aria-label={$_('menu.edit')}>
<FilePen size="18" class="md:hidden" /> <FilePen size="18" class="md:hidden" />
<span class="hidden md:block">{$_('menu.edit')}</span> <span class="hidden md:block">{$_('menu.edit')}</span>
</Menubar.Trigger> </Menubar.Trigger>
@@ -241,12 +243,47 @@
{/if} {/if}
<Shortcut key="H" ctrl={true} /> <Shortcut key="H" ctrl={true} />
</Menubar.Item> </Menubar.Item>
{#if $verticalFileView}
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
<Menubar.Separator />
<Menubar.Item
on:click={() => dbUtils.addNewTrack($selection.getSelected()[0].getFileId())}
disabled={$selection.size !== 1}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_track')}
</Menubar.Item>
{:else if $selection.getSelected().some((item) => item instanceof ListTrackItem)}
<Menubar.Separator />
<Menubar.Item
on:click={() => {
let item = $selection.getSelected()[0];
dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex());
}}
disabled={$selection.size !== 1}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_segment')}
</Menubar.Item>
{/if}
{/if}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item on:click={selectAll} disabled={$fileObservers.size == 0}> <Menubar.Item on:click={selectAll} disabled={$fileObservers.size == 0}>
<FileStack size="16" class="mr-1" /> <FileStack size="16" class="mr-1" />
{$_('menu.select_all')} {$_('menu.select_all')}
<Shortcut key="A" ctrl={true} /> <Shortcut key="A" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item
on:click={() => {
if ($selection.size > 0) {
centerMapOnSelection();
}
}}
>
<Maximize size="16" class="mr-1" />
{$_('menu.center')}
<Shortcut key="⏎" ctrl={true} />
</Menubar.Item>
{#if $verticalFileView} {#if $verticalFileView}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item on:click={copySelection} disabled={$selection.size === 0}> <Menubar.Item on:click={copySelection} disabled={$selection.size === 0}>
@@ -280,7 +317,7 @@
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
<Menubar.Menu> <Menubar.Menu>
<Menubar.Trigger> <Menubar.Trigger aria-label={$_('menu.view')}>
<View size="18" class="md:hidden" /> <View size="18" class="md:hidden" />
<span class="hidden md:block">{$_('menu.view')}</span> <span class="hidden md:block">{$_('menu.view')}</span>
</Menubar.Trigger> </Menubar.Trigger>
@@ -318,14 +355,14 @@
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
<Menubar.Menu> <Menubar.Menu>
<Menubar.Trigger> <Menubar.Trigger aria-label={$_('menu.settings')}>
<Settings size="18" class="md:hidden" /> <Settings size="18" class="md:hidden" />
<span class="hidden md:block"> <span class="hidden md:block">
{$_('menu.settings')} {$_('menu.settings')}
</span> </span>
</Menubar.Trigger> </Menubar.Trigger>
<Menubar.Content class="border-none" <Menubar.Content class="border-none">
><Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<Ruler size="16" class="mr-1" />{$_('menu.distance_units')} <Ruler size="16" class="mr-1" />{$_('menu.distance_units')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
@@ -333,13 +370,14 @@
<Menubar.RadioGroup bind:value={$distanceUnits}> <Menubar.RadioGroup bind:value={$distanceUnits}>
<Menubar.RadioItem value="metric">{$_('menu.metric')}</Menubar.RadioItem> <Menubar.RadioItem value="metric">{$_('menu.metric')}</Menubar.RadioItem>
<Menubar.RadioItem value="imperial">{$_('menu.imperial')}</Menubar.RadioItem> <Menubar.RadioItem value="imperial">{$_('menu.imperial')}</Menubar.RadioItem>
<Menubar.RadioItem value="nautical">{$_('menu.nautical')}</Menubar.RadioItem>
</Menubar.RadioGroup> </Menubar.RadioGroup>
</Menubar.SubContent> </Menubar.SubContent>
</Menubar.Sub> </Menubar.Sub>
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger <Menubar.SubTrigger>
><Zap size="16" class="mr-1" />{$_('menu.velocity_units')}</Menubar.SubTrigger <Zap size="16" class="mr-1" />{$_('menu.velocity_units')}
> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={$velocityUnits}> <Menubar.RadioGroup bind:value={$velocityUnits}>
<Menubar.RadioItem value="speed">{$_('quantities.speed')}</Menubar.RadioItem> <Menubar.RadioItem value="speed">{$_('quantities.speed')}</Menubar.RadioItem>
@@ -367,7 +405,7 @@
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={$locale}> <Menubar.RadioGroup bind:value={$locale}>
{#each Object.entries(languages) as [lang, label]} {#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang)}> <a href={getURLForLanguage(lang, '/app')}>
<Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem> <Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem>
</a> </a>
{/each} {/each}
@@ -409,7 +447,7 @@
</Menubar.SubContent> </Menubar.SubContent>
</Menubar.Sub> </Menubar.Sub>
<Menubar.Item on:click={() => (layerSettingsOpen = true)}> <Menubar.Item on:click={() => (layerSettingsOpen = true)}>
<Layers3 size="16" class="mr-1" /> <Layers size="16" class="mr-1" />
{$_('menu.layers')} {$_('menu.layers')}
</Menubar.Item> </Menubar.Item>
</Menubar.Content> </Menubar.Content>
@@ -421,6 +459,7 @@
href="./help" href="./help"
target="_blank" target="_blank"
class="cursor-default h-fit rounded-sm px-3 py-0.5" class="cursor-default h-fit rounded-sm px-3 py-0.5"
aria-label={$_('menu.help')}
> >
<BookOpenText size="18" class="md:hidden" /> <BookOpenText size="18" class="md:hidden" />
<span class="hidden md:block"> <span class="hidden md:block">
@@ -432,6 +471,7 @@
href="https://ko-fi.com/gpxstudio" href="https://ko-fi.com/gpxstudio"
target="_blank" target="_blank"
class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5" class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5"
aria-label={$_('menu.donate')}
> >
<HeartHandshake size="18" class="md:hidden" /> <HeartHandshake size="18" class="md:hidden" />
<span class="hidden md:flex flex-row items-center"> <span class="hidden md:flex flex-row items-center">
@@ -498,13 +538,16 @@
} else { } else {
dbUtils.undo(); dbUtils.undo();
} }
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
dbUtils.deleteAllFiles();
} else {
dbUtils.deleteSelection();
}
e.preventDefault(); e.preventDefault();
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
if (e.shiftKey) {
dbUtils.deleteAllFiles();
} else {
dbUtils.deleteSelection();
}
e.preventDefault();
}
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { if (!targetInput) {
selectAll(); selectAll();
@@ -533,6 +576,10 @@
dbUtils.setHiddenToSelection(true); dbUtils.setHiddenToSelection(true);
} }
e.preventDefault(); e.preventDefault();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
if ($selection.size > 0) {
centerMapOnSelection();
}
} else if (e.key === 'F1') { } else if (e.key === 'F1') {
switchBasemaps(); switchBasemaps();
e.preventDefault(); e.preventDefault();

View File

@@ -15,6 +15,7 @@
on:click={() => { on:click={() => {
setMode(selectedMode === 'light' ? 'dark' : 'light'); setMode(selectedMode === 'light' ? 'dark' : 'light');
}} }}
aria-label={$_('menu.mode')}
> >
{#if selectedMode === 'light'} {#if selectedMode === 'light'}
<Sun {size} /> <Sun {size} />

View File

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

View File

@@ -1,26 +1,36 @@
<script lang="ts"> <script lang="ts">
import { isMac, isSafari } from '$lib/utils';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
export let key: string; export let key: string | undefined = undefined;
export let shift: boolean = false; export let shift: boolean = false;
export let ctrl: boolean = false; export let ctrl: boolean = false;
export let click: boolean = false; export let click: boolean = false;
let isMac = false; let mac = false;
let isSafari = false; let safari = false;
onMount(() => { onMount(() => {
isMac = navigator.userAgent.toUpperCase().indexOf('MAC') >= 0; mac = isMac();
isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); safari = isSafari();
}); });
</script> </script>
<div <div
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline" class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
{...$$props}
> >
<span>{shift ? '⇧' : ''}</span> {#if shift}
<span>{ctrl ? (isMac && !isSafari ? '⌘' : $_('menu.ctrl') + '+') : ''}</span> <span></span>
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span> {/if}
<span>{click ? $_('menu.click') : ''}</span> {#if ctrl}
<span>{mac && !safari ? '⌘' : $_('menu.ctrl') + '+'}</span>
{/if}
{#if key}
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
{/if}
{#if click}
<span>{$_('menu.click')}</span>
{/if}
</div> </div>

View File

@@ -1,14 +1,18 @@
<script lang="ts"> <script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip/index.js'; import * as Tooltip from '$lib/components/ui/tooltip/index.js';
export let label: string;
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top'; export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
</script> </script>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger {...$$restProps}> <Tooltip.Trigger {...$$restProps} aria-label={label}>
<slot name="data" /> <slot />
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content {side}> <Tooltip.Content {side}>
<slot name="tooltip" /> <div class="flex flex-row items-center">
<span>{label}</span>
<slot name="extra" />
</div>
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>

View File

@@ -1,29 +0,0 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { settings } from '$lib/db';
const { showWelcomeMessage } = settings;
</script>
<AlertDialog.Root
open={$showWelcomeMessage === true}
closeOnEscape={false}
closeOnOutsideClick={false}
onOpenChange={() => ($showWelcomeMessage = false)}
>
<AlertDialog.Trigger class="hidden"></AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Welcome to the new version of <b>gpx.studio</b>!
</AlertDialog.Title>
<AlertDialog.Description class="space-y-1">
<p>The website is still under development and may contain bugs.</p>
<p>Please report any issues you find by email or on GitHub.</p>
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Action>Let's go!</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -2,9 +2,12 @@
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { import {
celsiusToFahrenheit, celsiusToFahrenheit,
distancePerHourToSecondsPerDistance, getConvertedDistance,
kilometersToMiles, getConvertedElevation,
metersToFeet, getConvertedVelocity,
getDistanceUnits,
getElevationUnits,
getVelocityUnits,
secondsToHHMMSS secondsToHHMMSS
} from '$lib/units'; } from '$lib/units';
@@ -20,31 +23,18 @@
<span class={$$props.class}> <span class={$$props.class}>
{#if type === 'distance'} {#if type === 'distance'}
{#if $distanceUnits === 'metric'} {getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers') : ''} {showUnits ? getDistanceUnits($distanceUnits) : ''}
{:else}
{kilometersToMiles(value).toFixed(decimals ?? 2)} {showUnits ? $_('units.miles') : ''}
{/if}
{:else if type === 'elevation'} {:else if type === 'elevation'}
{#if $distanceUnits === 'metric'} {getConvertedElevation(value, $distanceUnits).toFixed(decimals ?? 0)}
{value.toFixed(decimals ?? 0)} {showUnits ? $_('units.meters') : ''} {showUnits ? getElevationUnits($distanceUnits) : ''}
{:else}
{metersToFeet(value).toFixed(decimals ?? 0)} {showUnits ? $_('units.feet') : ''}
{/if}
{:else if type === 'speed'} {:else if type === 'speed'}
{#if $distanceUnits === 'metric'} {#if $velocityUnits === 'speed'}
{#if $velocityUnits === 'speed'} {getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers_per_hour') : ''} {showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{:else}
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(value))}
{showUnits ? $_('units.minutes_per_kilometer') : ''}
{/if}
{:else if $velocityUnits === 'speed'}
{kilometersToMiles(value).toFixed(decimals ?? 2)}
{showUnits ? $_('units.miles_per_hour') : ''}
{:else} {:else}
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(kilometersToMiles(value)))} {secondsToHHMMSS(getConvertedVelocity(value, $velocityUnits, $distanceUnits))}
{showUnits ? $_('units.minutes_per_mile') : ''} {showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{/if} {/if}
{:else if type === 'temperature'} {:else if type === 'temperature'}
{#if $temperatureUnits === 'celsius'} {#if $temperatureUnits === 'celsius'}

View File

@@ -1,42 +1,12 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { _ } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { _, locale } from 'svelte-i18n';
export let path: string; export let module;
export let titleOnly: boolean = false;
let module = undefined;
let metadata: Record<string, any> = {};
const modules = import.meta.glob('/src/lib/docs/**/*.mdx');
function loadModule(path: string) {
modules[path]?.().then((mod) => {
module = mod.default;
metadata = mod.metadata;
});
}
$: if ($locale) {
if (modules.hasOwnProperty(`/src/lib/docs/${$locale}/${path}`)) {
loadModule(`/src/lib/docs/${$locale}/${path}`);
} else if (browser) {
goto(`${base}/404`);
}
}
</script> </script>
{#if module !== undefined} <div class="markdown flex flex-col gap-3">
{#if titleOnly} <svelte:component this={module} />
{metadata.title} </div>
{:else}
<div class="markdown flex flex-col gap-3">
<svelte:component this={module} />
</div>
{/if}
{/if}
<style lang="postcss"> <style lang="postcss">
:global(.markdown) { :global(.markdown) {
@@ -64,24 +34,24 @@
@apply pt-1.5; @apply pt-1.5;
} }
:global(.markdown p > button) { :global(.markdown p > button, .markdown li > button) {
@apply border; @apply border;
@apply rounded-md; @apply rounded-md;
@apply px-1; @apply px-1;
} }
:global(.markdown > a) { :global(.markdown > a) {
@apply text-blue-500; @apply text-link;
@apply hover:underline; @apply hover:underline;
} }
:global(.markdown p > a) { :global(.markdown p > a) {
@apply text-blue-500; @apply text-link;
@apply hover:underline; @apply hover:underline;
} }
:global(.markdown li > a) { :global(.markdown li > a) {
@apply text-blue-500; @apply text-link;
@apply hover:underline; @apply hover:underline;
} }

View File

@@ -1,11 +1,25 @@
<script lang="ts"> <script lang="ts">
export let src; export let src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
export let alt: string; export let alt: string;
</script> </script>
<div class="flex flex-col items-center py-6 w-full"> <div class="flex flex-col items-center py-6 w-full">
<div class="rounded-md overflow-clip shadow-xl mx-auto"> <div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
<enhanced:img {src} {alt} class="w-full max-w-3xl" /> {#if src === 'getting-started/interface'}
<enhanced:img
src="/src/lib/assets/img/docs/getting-started/interface.png"
{alt}
class="w-full max-w-3xl"
/>
{:else if src === 'tools/routing'}
<enhanced:img
src="/src/lib/assets/img/docs/tools/routing.png"
{alt}
class="w-full max-w-3xl"
/>
{:else if src === 'tools/split'}
<enhanced:img src="/src/lib/assets/img/docs/tools/split.png" {alt} class="w-full max-w-3xl" />
{/if}
</div> </div>
<p class="text-center text-sm text-muted-foreground mt-2">{alt}</p> <p class="text-center text-sm text-muted-foreground mt-2">{alt}</p>
</div> </div>

View File

@@ -3,8 +3,8 @@
</script> </script>
<div <div
class="bg-accent border-l-8 {type === 'note' class="bg-secondary border-l-8 {type === 'note'
? 'border-blue-500' ? 'border-link'
: 'border-destructive'} p-2 text-sm rounded-md" : 'border-destructive'} p-2 text-sm rounded-md"
> >
<slot /> <slot />
@@ -12,7 +12,7 @@
<style lang="postcss"> <style lang="postcss">
div :global(a) { div :global(a) {
@apply text-blue-500; @apply text-link;
@apply hover:underline; @apply hover:underline;
} }
</style> </style>

View File

@@ -1,14 +1,15 @@
import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer } from "lucide-svelte"; import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer, MountainSnow } from "lucide-svelte";
import type { ComponentType } from "svelte"; import type { ComponentType } from "svelte";
export const guides: Record<string, string[]> = { export const guides: Record<string, string[]> = {
'getting-started': [], 'getting-started': [],
menu: ['file', 'edit', 'view', 'settings'], menu: ['file', 'edit', 'view', 'settings'],
'files-and-stats': [], 'files-and-stats': [],
toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'minify', 'clean'], toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'elevation', 'minify', 'clean'],
'map-controls': [], 'map-controls': [],
'gpx': [], 'gpx': [],
'integration': [], 'integration': [],
'faq': [],
}; };
export const guideIcons: Record<string, string | ComponentType<Icon>> = { export const guideIcons: Record<string, string | ComponentType<Icon>> = {
@@ -26,11 +27,13 @@ export const guideIcons: Record<string, string | ComponentType<Icon>> = {
"time": CalendarClock, "time": CalendarClock,
"merge": Group, "merge": Group,
"extract": Ungroup, "extract": Ungroup,
"elevation": MountainSnow,
"minify": Filter, "minify": Filter,
"clean": SquareDashedMousePointer, "clean": SquareDashedMousePointer,
"map-controls": "🗺", "map-controls": "🗺",
"gpx": "💾", "gpx": "💾",
"integration": "{ 👩‍💻 }", "integration": "{ 👩‍💻 }",
"faq": "🔮",
}; };
export function getPreviousGuide(currentGuide: string): string | undefined { export function getPreviousGuide(currentGuide: string): string | undefined {

View File

@@ -20,8 +20,13 @@
import type { GPXFile } from 'gpx'; import type { GPXFile } from 'gpx';
import { selection } from '$lib/components/file-list/Selection'; import { selection } from '$lib/components/file-list/Selection';
import { ListFileItem } from '$lib/components/file-list/FileList'; import { ListFileItem } from '$lib/components/file-list/FileList';
import { allowedEmbeddingBasemaps, type EmbeddingOptions } from './Embedding'; import {
allowedEmbeddingBasemaps,
getFilesFromEmbeddingOptions,
type EmbeddingOptions
} from './Embedding';
import { mode, setMode } from 'mode-watcher'; import { mode, setMode } from 'mode-watcher';
import { browser } from '$app/environment';
$embedding = true; $embedding = true;
@@ -55,7 +60,7 @@
}); });
let downloads: Promise<GPXFile | null>[] = []; let downloads: Promise<GPXFile | null>[] = [];
options.files.forEach((url) => { getFilesFromEmbeddingOptions(options).forEach((url) => {
downloads.push( downloads.push(
fetch(url) fetch(url)
.then((response) => response.blob()) .then((response) => response.blob())
@@ -176,7 +181,7 @@
prevSettings.theme = $mode ?? 'system'; prevSettings.theme = $mode ?? 'system';
}); });
$: if (options) { $: if (browser && options) {
applyOptions(); applyOptions();
} }
@@ -224,7 +229,7 @@
geolocate={false} geolocate={false}
hash={useHash} hash={useHash}
/> />
<OpenIn bind:files={options.files} /> <OpenIn bind:files={options.files} bind:ids={options.ids} />
<LayerControl /> <LayerControl />
<GPXLayers /> <GPXLayers />
{#if $fileObservers.size > 1} {#if $fileObservers.size > 1}

View File

@@ -1,31 +1,34 @@
import { basemaps } from "$lib/assets/layers"; import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { basemaps } from '$lib/assets/layers';
export type EmbeddingOptions = { export type EmbeddingOptions = {
token: string; token: string;
files: string[]; files: string[];
ids: string[];
basemap: string; basemap: string;
elevation: { elevation: {
show: boolean; show: boolean;
height: number, height: number;
controls: boolean, controls: boolean;
fill: 'slope' | 'surface' | undefined, fill: 'slope' | 'surface' | undefined;
speed: boolean, speed: boolean;
hr: boolean, hr: boolean;
cad: boolean, cad: boolean;
temp: boolean, temp: boolean;
power: boolean, power: boolean;
}, };
distanceMarkers: boolean, distanceMarkers: boolean;
directionMarkers: boolean, directionMarkers: boolean;
distanceUnits: 'metric' | 'imperial', distanceUnits: 'metric' | 'imperial' | 'nautical';
velocityUnits: 'speed' | 'pace', velocityUnits: 'speed' | 'pace';
temperatureUnits: 'celsius' | 'fahrenheit', temperatureUnits: 'celsius' | 'fahrenheit';
theme: 'system' | 'light' | 'dark', theme: 'system' | 'light' | 'dark';
}; };
export const defaultEmbeddingOptions = { export const defaultEmbeddingOptions = {
token: '', token: '',
files: [], files: [],
ids: [],
basemap: 'mapboxOutdoors', basemap: 'mapboxOutdoors',
elevation: { elevation: {
show: true, show: true,
@@ -36,21 +39,24 @@ export const defaultEmbeddingOptions = {
hr: false, hr: false,
cad: false, cad: false,
temp: false, temp: false,
power: false, power: false
}, },
distanceMarkers: false, distanceMarkers: false,
directionMarkers: false, directionMarkers: false,
distanceUnits: 'metric', distanceUnits: 'metric',
velocityUnits: 'speed', velocityUnits: 'speed',
temperatureUnits: 'celsius', temperatureUnits: 'celsius',
theme: 'system', theme: 'system'
}; };
export function getDefaultEmbeddingOptions(): EmbeddingOptions { export function getDefaultEmbeddingOptions(): EmbeddingOptions {
return JSON.parse(JSON.stringify(defaultEmbeddingOptions)); return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
} }
export function getMergedEmbeddingOptions(options: any, defaultOptions: any = defaultEmbeddingOptions): EmbeddingOptions { export function getMergedEmbeddingOptions(
options: any,
defaultOptions: any = defaultEmbeddingOptions
): EmbeddingOptions {
const mergedOptions = JSON.parse(JSON.stringify(defaultOptions)); const mergedOptions = JSON.parse(JSON.stringify(defaultOptions));
for (const key in options) { for (const key in options) {
if (typeof options[key] === 'object' && options[key] !== null && !Array.isArray(options[key])) { if (typeof options[key] === 'object' && options[key] !== null && !Array.isArray(options[key])) {
@@ -62,10 +68,17 @@ export function getMergedEmbeddingOptions(options: any, defaultOptions: any = de
return mergedOptions; return mergedOptions;
} }
export function getCleanedEmbeddingOptions(options: any, defaultOptions: any = defaultEmbeddingOptions): any { export function getCleanedEmbeddingOptions(
options: any,
defaultOptions: any = defaultEmbeddingOptions
): any {
const cleanedOptions = JSON.parse(JSON.stringify(options)); const cleanedOptions = JSON.parse(JSON.stringify(options));
for (const key in cleanedOptions) { for (const key in cleanedOptions) {
if (typeof cleanedOptions[key] === 'object' && cleanedOptions[key] !== null && !Array.isArray(cleanedOptions[key])) { if (
typeof cleanedOptions[key] === 'object' &&
cleanedOptions[key] !== null &&
!Array.isArray(cleanedOptions[key])
) {
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]); cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
if (Object.keys(cleanedOptions[key]).length === 0) { if (Object.keys(cleanedOptions[key]).length === 0) {
delete cleanedOptions[key]; delete cleanedOptions[key];
@@ -77,4 +90,59 @@ export function getCleanedEmbeddingOptions(options: any, defaultOptions: any = d
return cleanedOptions; return cleanedOptions;
} }
export const allowedEmbeddingBasemaps = Object.keys(basemaps).filter(basemap => !['ordnanceSurvey'].includes(basemap)); export const allowedEmbeddingBasemaps = Object.keys(basemaps).filter(
(basemap) => !['ordnanceSurvey'].includes(basemap)
);
export function getFilesFromEmbeddingOptions(options: EmbeddingOptions): string[] {
return options.files.concat(options.ids.map((id) => getURLForGoogleDriveFile(id)));
}
export function getURLForGoogleDriveFile(fileId: string): string {
return `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&key=AIzaSyA2ZadQob_hXiT2VaYIkAyafPvz_4ZMssk`;
}
export function convertOldEmbeddingOptions(options: URLSearchParams): any {
let newOptions: any = {
token: PUBLIC_MAPBOX_TOKEN,
files: [],
ids: [],
};
if (options.has('state')) {
let state = JSON.parse(options.get('state')!);
if (state.ids) {
newOptions.ids.push(...state.ids);
}
if (state.urls) {
newOptions.files.push(...state.urls);
}
}
if (options.has('source')) {
let basemap = options.get('source')!;
if (basemap === 'satellite') {
newOptions.basemap = 'mapboxSatellite';
} else if (basemap === 'otm') {
newOptions.basemap = 'openTopoMap';
} else if (basemap === 'ohm') {
newOptions.basemap = 'openHikingMap';
}
}
if (options.has('imperial')) {
newOptions.distanceUnits = 'imperial';
}
if (options.has('running')) {
newOptions.velocityUnits = 'pace';
}
if (options.has('distance')) {
newOptions.distanceMarkers = true;
}
if (options.has('direction')) {
newOptions.directionMarkers = true;
}
if (options.has('slope')) {
newOptions.elevation = {
fill: 'slope'
};
}
return newOptions;
}

View File

@@ -34,13 +34,21 @@
]; ];
let files = options.files[0]; let files = options.files[0];
$: if (files) { $: {
let urls = files.split(','); let urls = files.split(',');
urls = urls.filter((url) => url.length > 0); urls = urls.filter((url) => url.length > 0);
if (JSON.stringify(urls) !== JSON.stringify(options.files)) { if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
options.files = urls; options.files = urls;
} }
} }
let driveIds = '';
$: {
let ids = driveIds.split(',');
ids = ids.filter((id) => id.length > 0);
if (JSON.stringify(ids) !== JSON.stringify(options.ids)) {
options.ids = ids;
}
}
let manualCamera = false; let manualCamera = false;
@@ -84,7 +92,7 @@
} }
</script> </script>
<Card.Root> <Card.Root id="embedding-playground">
<Card.Header> <Card.Header>
<Card.Title>{$_('embedding.title')}</Card.Title> <Card.Title>{$_('embedding.title')}</Card.Title>
</Card.Header> </Card.Header>
@@ -94,6 +102,8 @@
<Input id="token" type="text" class="h-8" bind:value={options.token} /> <Input id="token" type="text" class="h-8" bind:value={options.token} />
<Label for="file_urls">{$_('embedding.file_urls')}</Label> <Label for="file_urls">{$_('embedding.file_urls')}</Label>
<Input id="file_urls" type="text" class="h-8" bind:value={files} /> <Input id="file_urls" type="text" class="h-8" bind:value={files} />
<Label for="drive_ids">{$_('embedding.drive_ids')}</Label>
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
<Label for="basemap">{$_('embedding.basemap')}</Label> <Label for="basemap">{$_('embedding.basemap')}</Label>
<Select.Root <Select.Root
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }} selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
@@ -214,6 +224,10 @@
<RadioGroup.Item value="imperial" id="imperial" /> <RadioGroup.Item value="imperial" id="imperial" />
<Label for="imperial">{$_('menu.imperial')}</Label> <Label for="imperial">{$_('menu.imperial')}</Label>
</div> </div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="nautical" id="nautical" />
<Label for="nautical">{$_('menu.nautical')}</Label>
</div>
</RadioGroup.Root> </RadioGroup.Root>
</Label> </Label>
<Label class="flex flex-col items-start gap-2"> <Label class="flex flex-col items-start gap-2">

View File

@@ -1,18 +1,23 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Logo from '$lib/components/Logo.svelte'; import Logo from '$lib/components/Logo.svelte';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { _, locale } from 'svelte-i18n'; import { _, locale } from 'svelte-i18n';
export let files: string[]; export let files: string[];
export let ids: string[];
</script> </script>
<Button <Button
variant="ghost" variant="ghost"
class="absolute top-0 flex-wrap h-fit bg-background font-semibold rounded-md py-1 px-2 gap-1.5 xs:text-base mt-2.5 ml-2.5 mr-12" class="absolute top-0 flex-wrap h-fit bg-background font-semibold rounded-md py-1 px-2 gap-1.5 xs:text-base mt-2.5 ml-2.5 mr-12"
href="{getURLForLanguage($locale, '/app')}?files={encodeURIComponent(JSON.stringify(files))}" href="{getURLForLanguage($locale, '/app')}?{files.length > 0
target="_blank" ? `files=${encodeURIComponent(JSON.stringify(files))}`
: ''}{files.length > 0 && ids.length > 0 ? '&' : ''}{ids.length > 0
? `ids=${encodeURIComponent(JSON.stringify(ids))}`
: ''}"
target="_blank"
> >
{$_('menu.open_in')} {$_('menu.open_in')}
<Logo class="h-[18px] xs:h-5 translate-y-[1px]" /> <Logo class="h-[18px] xs:h-5 translate-y-[1px]" />
</Button> </Button>

View File

@@ -5,10 +5,17 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx'; import {
buildGPX,
GPXFile,
Track,
Waypoint,
type AnyGPXTreeElement,
type GPXTreeElement
} from 'gpx';
import { afterUpdate, getContext, onDestroy, onMount } from 'svelte'; import { afterUpdate, getContext, onDestroy, onMount } from 'svelte';
import Sortable from 'sortablejs/Sortable'; import Sortable from 'sortablejs/Sortable';
import { getFileIds, settings, type GPXFileWithStatistics } from '$lib/db'; import { getFile, getFileIds, settings, type GPXFileWithStatistics } from '$lib/db';
import { get, writable, type Readable, type Writable } from 'svelte/store'; import { get, writable, type Readable, type Writable } from 'svelte/store';
import FileListNodeStore from './FileListNodeStore.svelte'; import FileListNodeStore from './FileListNodeStore.svelte';
import FileListNode from './FileListNode.svelte'; import FileListNode from './FileListNode.svelte';
@@ -22,6 +29,7 @@
type ListItem type ListItem
} from './FileList'; } from './FileList';
import { selection } from './Selection'; import { selection } from './Selection';
import { isMac } from '$lib/utils';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
export let node: export let node:
@@ -154,7 +162,7 @@
direction: orientation, direction: orientation,
forceAutoScrollFallback: true, forceAutoScrollFallback: true,
multiDrag: true, multiDrag: true,
multiDragKey: 'Meta', multiDragKey: isMac() ? 'Meta' : 'Ctrl',
avoidImplicitDeselect: true, avoidImplicitDeselect: true,
onSelect: updateToSelection, onSelect: updateToSelection,
onDeselect: updateToSelection, onDeselect: updateToSelection,
@@ -223,6 +231,22 @@
moveItems(fromItem, toItem, fromItems, toItems); moveItems(fromItem, toItem, fromItems, toItems);
} }
},
setData: function (dataTransfer: DataTransfer, dragEl: HTMLElement) {
if (sortableLevel === ListLevel.FILE) {
const fileId = dragEl.getAttribute('data-id');
const file = fileId ? getFile(fileId) : null;
if (file) {
const data = buildGPX(file);
dataTransfer.setData(
'DownloadURL',
`application/gpx+xml:${file.metadata.name}.gpx:data:text/octet-stream;charset=utf-8,${encodeURIComponent(data)}`
);
dataTransfer.dropEffect = 'copy';
dataTransfer.effectAllowed = 'copy';
}
}
} }
}); });
Object.defineProperty(sortable, '_item', { Object.defineProperty(sortable, '_item', {

View File

@@ -15,6 +15,7 @@
EyeOff, EyeOff,
ClipboardCopy, ClipboardCopy,
ClipboardPaste, ClipboardPaste,
Maximize,
Scissors, Scissors,
FileStack, FileStack,
FileX FileX
@@ -39,7 +40,15 @@
} from './Selection'; } from './Selection';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { allHidden, editMetadata, editStyle, embedding, gpxLayers, map } from '$lib/stores'; import {
allHidden,
editMetadata,
editStyle,
embedding,
centerMapOnSelection,
gpxLayers,
map
} from '$lib/stores';
import { import {
GPXTreeElement, GPXTreeElement,
Track, Track,
@@ -239,10 +248,7 @@
{#if item instanceof ListFileItem} {#if item instanceof ListFileItem}
<ContextMenu.Item <ContextMenu.Item
disabled={!singleSelection} disabled={!singleSelection}
on:click={() => on:click={() => dbUtils.addNewTrack(item.getFileId())}
dbUtils.applyToFile(item.getFileId(), (file) =>
file.replaceTracks(file.trk.length, file.trk.length, [new Track()])
)}
> >
<Plus size="16" class="mr-1" /> <Plus size="16" class="mr-1" />
{$_('menu.new_track')} {$_('menu.new_track')}
@@ -251,17 +257,7 @@
{:else if item instanceof ListTrackItem} {:else if item instanceof ListTrackItem}
<ContextMenu.Item <ContextMenu.Item
disabled={!singleSelection} disabled={!singleSelection}
on:click={() => { on:click={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())}
let trackIndex = item.getTrackIndex();
dbUtils.applyToFile(item.getFileId(), (file) =>
file.replaceTrackSegments(
trackIndex,
file.trk[trackIndex].trkseg.length,
file.trk[trackIndex].trkseg.length,
[new TrackSegment()]
)
);
}}
> >
<Plus size="16" class="mr-1" /> <Plus size="16" class="mr-1" />
{$_('menu.new_segment')} {$_('menu.new_segment')}
@@ -275,38 +271,41 @@
{$_('menu.select_all')} {$_('menu.select_all')}
<Shortcut key="A" ctrl={true} /> <Shortcut key="A" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator />
{/if} {/if}
<ContextMenu.Item on:click={centerMapOnSelection}>
<Maximize size="16" class="mr-1" />
{$_('menu.center')}
<Shortcut key="⏎" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
<Copy size="16" class="mr-1" />
{$_('menu.duplicate')}
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
>
{#if orientation === 'vertical'} {#if orientation === 'vertical'}
<ContextMenu.Item on:click={dbUtils.duplicateSelection}> <ContextMenu.Item on:click={copySelection}>
<Copy size="16" class="mr-1" /> <ClipboardCopy size="16" class="mr-1" />
{$_('menu.duplicate')} {$_('menu.copy')}
<Shortcut key="D" ctrl={true} /></ContextMenu.Item <Shortcut key="C" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item on:click={cutSelection}>
<Scissors size="16" class="mr-1" />
{$_('menu.cut')}
<Shortcut key="X" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(item.level)}
on:click={pasteSelection}
> >
{#if orientation === 'vertical'} <ClipboardPaste size="16" class="mr-1" />
<ContextMenu.Item on:click={copySelection}> {$_('menu.paste')}
<ClipboardCopy size="16" class="mr-1" /> <Shortcut key="V" ctrl={true} />
{$_('menu.copy')} </ContextMenu.Item>
<Shortcut key="C" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item on:click={cutSelection}>
<Scissors size="16" class="mr-1" />
{$_('menu.cut')}
<Shortcut key="X" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(item.level)}
on:click={pasteSelection}
>
<ClipboardPaste size="16" class="mr-1" />
{$_('menu.paste')}
<Shortcut key="V" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Separator />
{/if} {/if}
<ContextMenu.Separator />
<ContextMenu.Item on:click={dbUtils.deleteSelection}> <ContextMenu.Item on:click={dbUtils.deleteSelection}>
{#if item instanceof ListFileItem} {#if item instanceof ListFileItem}
<FileX size="16" class="mr-1" /> <FileX size="16" class="mr-1" />

View File

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

View File

@@ -1,10 +1,9 @@
import { font } from "$lib/assets/layers";
import { settings } from "$lib/db"; import { settings } from "$lib/db";
import { gpxStatistics } from "$lib/stores"; import { gpxStatistics } from "$lib/stores";
import { get } from "svelte/store"; import { get } from "svelte/store";
const { distanceMarkers, distanceUnits, currentBasemap } = settings; const { distanceMarkers, distanceUnits } = settings;
export class DistanceMarkers { export class DistanceMarkers {
map: mapboxgl.Map; map: mapboxgl.Map;
@@ -17,7 +16,7 @@ export class DistanceMarkers {
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded)); this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(distanceMarkers.subscribe(this.updateBinded)); this.unsubscribes.push(distanceMarkers.subscribe(this.updateBinded));
this.unsubscribes.push(distanceUnits.subscribe(this.updateBinded)); this.unsubscribes.push(distanceUnits.subscribe(this.updateBinded));
this.map.on('style.load', this.updateBinded); this.map.on('style.import.load', this.updateBinded);
} }
update() { update() {
@@ -40,7 +39,7 @@ export class DistanceMarkers {
layout: { layout: {
'text-field': ['get', 'distance'], 'text-field': ['get', 'distance'],
'text-size': 14, 'text-size': 14,
'text-font': [font[get(currentBasemap)] ?? 'Open Sans Bold'], 'text-font': ['Open Sans Bold'],
'text-padding': 20, 'text-padding': 20,
}, },
paint: { paint: {

View File

@@ -7,7 +7,6 @@ import { addSelectItem, selectItem, selection } from "$lib/components/file-list/
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList"; import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
import type { Waypoint } from "gpx"; import type { Waypoint } from "gpx";
import { getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils"; import { getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
import { font } from "$lib/assets/layers";
import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte"; import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
import { MapPin, Square } from "lucide-static"; import { MapPin, Square } from "lucide-static";
import { getSymbolKey, symbols } from "$lib/assets/symbols"; import { getSymbolKey, symbols } from "$lib/assets/symbols";
@@ -66,7 +65,7 @@ function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
</svg>`; </svg>`;
} }
const { directionMarkers, verticalFileView, currentBasemap, defaultOpacity, defaultWeight } = settings; const { directionMarkers, verticalFileView, defaultOpacity, defaultWeight } = settings;
export class GPXLayer { export class GPXLayer {
map: mapboxgl.Map; map: mapboxgl.Map;
@@ -82,6 +81,7 @@ export class GPXLayer {
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this); layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this); layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this); layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
maybeHideWaypointPopupBinded: (e: any) => void = this.maybeHideWaypointPopup.bind(this); maybeHideWaypointPopupBinded: (e: any) => void = this.maybeHideWaypointPopup.bind(this);
constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>) { constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
@@ -112,7 +112,7 @@ export class GPXLayer {
})); }));
this.draggable = get(currentTool) === Tool.WAYPOINT; this.draggable = get(currentTool) === Tool.WAYPOINT;
this.map.on('style.load', this.updateBinded); this.map.on('style.import.load', this.updateBinded);
} }
update() { update() {
@@ -154,6 +154,7 @@ export class GPXLayer {
}); });
this.map.on('click', this.fileId, this.layerOnClickBinded); this.map.on('click', this.fileId, this.layerOnClickBinded);
this.map.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
this.map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded); this.map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
this.map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded); this.map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
} }
@@ -170,7 +171,7 @@ export class GPXLayer {
'text-keep-upright': false, 'text-keep-upright': false,
'text-max-angle': 361, 'text-max-angle': 361,
'text-allow-overlap': true, 'text-allow-overlap': true,
'text-font': [font[get(currentBasemap)] ?? 'Open Sans Bold'], 'text-font': ['Open Sans Bold'],
'symbol-placement': 'line', 'symbol-placement': 'line',
'symbol-spacing': 20, 'symbol-spacing': 20,
}, },
@@ -262,14 +263,16 @@ export class GPXLayer {
marker.on('dragend', (e) => { marker.on('dragend', (e) => {
resetCursor(); resetCursor();
marker.getElement().style.cursor = ''; marker.getElement().style.cursor = '';
dbUtils.applyToFile(this.fileId, (file) => { getElevation([marker._waypoint]).then((ele) => {
let latLng = marker.getLngLat(); dbUtils.applyToFile(this.fileId, (file) => {
let wpt = file.wpt[marker._waypoint._data.index]; let latLng = marker.getLngLat();
wpt.setCoordinates({ let wpt = file.wpt[marker._waypoint._data.index];
lat: latLng.lat, wpt.setCoordinates({
lon: latLng.lng lat: latLng.lat,
lon: latLng.lng
});
wpt.ele = ele[0];
}); });
wpt.ele = getElevation(this.map, wpt.getCoordinates());
}); });
dragEndTimestamp = Date.now() dragEndTimestamp = Date.now()
}); });
@@ -294,16 +297,17 @@ export class GPXLayer {
updateMap(map: mapboxgl.Map) { updateMap(map: mapboxgl.Map) {
this.map = map; this.map = map;
this.map.on('style.load', this.updateBinded); this.map.on('style.import.load', this.updateBinded);
this.update(); this.update();
} }
remove() { remove() {
if (get(map)) { if (get(map)) {
this.map.off('click', this.fileId, this.layerOnClickBinded); this.map.off('click', this.fileId, this.layerOnClickBinded);
this.map.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
this.map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded); this.map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
this.map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded); this.map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
this.map.off('style.load', this.updateBinded); this.map.off('style.import.load', this.updateBinded);
if (this.map.getLayer(this.fileId + '-direction')) { if (this.map.getLayer(this.fileId + '-direction')) {
this.map.removeLayer(this.fileId + '-direction'); this.map.removeLayer(this.fileId + '-direction');
@@ -381,6 +385,12 @@ export class GPXLayer {
} }
} }
layerOnContextMenu(e: any) {
if (e.originalEvent.ctrlKey) {
this.layerOnClick(e);
}
}
showWaypointPopup(waypoint: Waypoint) { showWaypointPopup(waypoint: Waypoint) {
if (get(currentPopupWaypoint) !== null) { if (get(currentPopupWaypoint) !== null) {
this.hideWaypointPopup(); this.hideWaypointPopup();

View File

@@ -24,13 +24,13 @@
if (text === undefined) { if (text === undefined) {
return ''; return '';
} }
let sanitized = sanitizeHtml(text, { return sanitizeHtml(text, {
allowedTags: ['a', 'br'], allowedTags: ['a', 'br', 'img'],
allowedAttributes: { allowedAttributes: {
a: ['href', 'target'] a: ['href', 'target'],
img: ['src']
} }
}).trim(); }).trim();
return sanitized;
} }
</script> </script>
@@ -89,7 +89,7 @@
> >
<Trash2 size="16" class="mr-1" /> <Trash2 size="16" class="mr-1" />
{$_('menu.delete')} {$_('menu.delete')}
<Shortcut key="" shift={true} click={true} /> <Shortcut shift={true} click={true} />
</Button> </Button>
{/if} {/if}
</Card.Content> </Card.Content>
@@ -99,7 +99,12 @@
<style lang="postcss"> <style lang="postcss">
div :global(a) { div :global(a) {
@apply text-blue-500 dark:text-blue-300; @apply text-link;
@apply hover:underline; @apply hover:underline;
} }
div :global(img) {
@apply my-0;
@apply rounded-md;
}
</style> </style>

View File

@@ -1,417 +1,435 @@
<script lang="ts"> <script lang="ts">
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import * as RadioGroup from '$lib/components/ui/radio-group'; import * as RadioGroup from '$lib/components/ui/radio-group';
import { import {
CirclePlus, CirclePlus,
CircleX, CircleX,
Minus, Minus,
Pencil, Pencil,
Plus, Plus,
Save, Save,
Trash2, Trash2,
Move, Move,
Map, Map,
Layers2 Layers2
} from 'lucide-svelte'; } from 'lucide-svelte';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { defaultBasemap, extendBasemap, type CustomLayer } from '$lib/assets/layers'; import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
import { map } from '$lib/stores'; import { map } from '$lib/stores';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import Sortable from 'sortablejs/Sortable'; import Sortable from 'sortablejs/Sortable';
import { customBasemapUpdate } from './utils';
const { const {
customLayers, customLayers,
selectedBasemapTree, selectedBasemapTree,
selectedOverlayTree, selectedOverlayTree,
currentBasemap, currentBasemap,
previousBasemap, previousBasemap,
currentOverlays, currentOverlays,
previousOverlays, previousOverlays,
customBasemapOrder, customBasemapOrder,
customOverlayOrder customOverlayOrder
} = settings; } = settings;
let name: string = ''; let name: string = '';
let tileUrls: string[] = ['']; let tileUrls: string[] = [''];
let maxZoom: number = 20; let maxZoom: number = 20;
let layerType: 'basemap' | 'overlay' = 'basemap'; let layerType: 'basemap' | 'overlay' = 'basemap';
let resourceType: 'raster' | 'vector' = 'raster'; let resourceType: 'raster' | 'vector' = 'raster';
let basemapContainer: HTMLElement; let basemapContainer: HTMLElement;
let overlayContainer: HTMLElement; let overlayContainer: HTMLElement;
let basemapSortable: Sortable; let basemapSortable: Sortable;
let overlaySortable: Sortable; let overlaySortable: Sortable;
onMount(() => { onMount(() => {
if ($customBasemapOrder.length === 0) { if ($customBasemapOrder.length === 0) {
$customBasemapOrder = Object.keys($customLayers).filter( $customBasemapOrder = Object.keys($customLayers).filter(
(id) => $customLayers[id].layerType === 'basemap' (id) => $customLayers[id].layerType === 'basemap'
); );
} }
if ($customOverlayOrder.length === 0) { if ($customOverlayOrder.length === 0) {
$customOverlayOrder = Object.keys($customLayers).filter( $customOverlayOrder = Object.keys($customLayers).filter(
(id) => $customLayers[id].layerType === 'overlay' (id) => $customLayers[id].layerType === 'overlay'
); );
} }
basemapSortable = Sortable.create(basemapContainer, { basemapSortable = Sortable.create(basemapContainer, {
onSort: (e) => { onSort: (e) => {
$customBasemapOrder = basemapSortable.toArray(); $customBasemapOrder = basemapSortable.toArray();
$selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => { $selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => {
acc[id] = true; acc[id] = true;
return acc; return acc;
}, {}); }, {});
} }
}); });
overlaySortable = Sortable.create(overlayContainer, { overlaySortable = Sortable.create(overlayContainer, {
onSort: (e) => { onSort: (e) => {
$customOverlayOrder = overlaySortable.toArray(); $customOverlayOrder = overlaySortable.toArray();
$selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => { $selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => {
acc[id] = true; acc[id] = true;
return acc; return acc;
}, {}); }, {});
} }
}); });
basemapSortable.sort($customBasemapOrder); basemapSortable.sort($customBasemapOrder);
overlaySortable.sort($customOverlayOrder); overlaySortable.sort($customOverlayOrder);
}); });
onDestroy(() => { onDestroy(() => {
basemapSortable.destroy(); basemapSortable.destroy();
overlaySortable.destroy(); overlaySortable.destroy();
}); });
$: if (tileUrls[0].length > 0) { $: if (tileUrls[0].length > 0) {
if ( if (
tileUrls[0].includes('.json') || tileUrls[0].includes('.json') ||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles')) (tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
) { ) {
resourceType = 'vector'; resourceType = 'vector';
layerType = 'basemap'; } else {
} else { resourceType = 'raster';
resourceType = 'raster'; }
} }
}
function createLayer() { function createLayer() {
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) { if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
deleteLayer(selectedLayerId); deleteLayer(selectedLayerId);
} }
if (typeof maxZoom === 'string') { if (typeof maxZoom === 'string') {
maxZoom = parseInt(maxZoom); maxZoom = parseInt(maxZoom);
} }
let layerId = selectedLayerId ?? getLayerId(); let layerId = selectedLayerId ?? getLayerId();
let layer: CustomLayer = { let layer: CustomLayer = {
id: layerId, id: layerId,
name: name, name: name,
tileUrls: tileUrls, tileUrls: tileUrls.map((url) => decodeURI(url.trim())),
maxZoom: maxZoom, maxZoom: maxZoom,
layerType: layerType, layerType: layerType,
resourceType: resourceType, resourceType: resourceType,
value: '' value: ''
}; };
if (resourceType === 'vector') { if (resourceType === 'vector') {
layer.value = tileUrls[0]; layer.value = layer.tileUrls[0];
} else { } else {
if (layerType === 'basemap') { layer.value = {
layer.value = extendBasemap({ version: 8,
version: 8, sources: {
sources: { [layerId]: {
[layerId]: { type: 'raster',
type: 'raster', tiles: layer.tileUrls,
tiles: tileUrls, tileSize: 256,
maxzoom: maxZoom maxzoom: maxZoom
} }
}, },
layers: [ layers: [
{ {
id: layerId, id: layerId,
type: 'raster', type: 'raster',
source: layerId source: layerId
} }
] ]
}); };
} else { }
layer.value = { $customLayers[layerId] = layer;
type: 'raster', addLayer(layerId);
tiles: tileUrls, selectedLayerId = undefined;
maxzoom: maxZoom setDataFromSelectedLayer();
}; }
}
}
$customLayers[layerId] = layer;
addLayer(layerId);
selectedLayerId = undefined;
setDataFromSelectedLayer();
}
function getLayerId() { function getLayerId() {
for (let id = 0; ; id++) { for (let id = 0; ; id++) {
if (!$customLayers.hasOwnProperty(`custom-${id}`)) { if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
return `custom-${id}`; return `custom-${id}`;
} }
} }
} }
function addLayer(layerId: string) { function addLayer(layerId: string) {
if (layerType === 'basemap') { if (layerType === 'basemap') {
selectedBasemapTree.update(($tree) => { selectedBasemapTree.update(($tree) => {
if (!$tree.basemaps.hasOwnProperty('custom')) { if (!$tree.basemaps.hasOwnProperty('custom')) {
$tree.basemaps['custom'] = {}; $tree.basemaps['custom'] = {};
} }
$tree.basemaps['custom'][layerId] = true; $tree.basemaps['custom'][layerId] = true;
return $tree; return $tree;
}); });
$currentBasemap = layerId; if ($currentBasemap === layerId) {
$customBasemapUpdate++;
} else {
$currentBasemap = layerId;
}
if (!$customBasemapOrder.includes(layerId)) { if (!$customBasemapOrder.includes(layerId)) {
$customBasemapOrder = [...$customBasemapOrder, layerId]; $customBasemapOrder = [...$customBasemapOrder, layerId];
} }
} else { } else {
selectedOverlayTree.update(($tree) => { selectedOverlayTree.update(($tree) => {
if (!$tree.overlays.hasOwnProperty('custom')) { if (!$tree.overlays.hasOwnProperty('custom')) {
$tree.overlays['custom'] = {}; $tree.overlays['custom'] = {};
} }
$tree.overlays['custom'][layerId] = true; $tree.overlays['custom'][layerId] = true;
return $tree; return $tree;
}); });
if ($map && $map.getSource(layerId)) { if (
// Reset source when updating an existing layer $currentOverlays.overlays['custom'] &&
if ($map.getLayer(layerId)) { $currentOverlays.overlays['custom'][layerId] &&
$map.removeLayer(layerId); $map
} ) {
$map.removeSource(layerId); try {
} $map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
if (!$currentOverlays.overlays.hasOwnProperty('custom')) { if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
$currentOverlays.overlays['custom'] = {}; $currentOverlays.overlays['custom'] = {};
} }
$currentOverlays.overlays['custom'][layerId] = true; $currentOverlays.overlays['custom'][layerId] = true;
if (!$customOverlayOrder.includes(layerId)) { if (!$customOverlayOrder.includes(layerId)) {
$customOverlayOrder = [...$customOverlayOrder, layerId]; $customOverlayOrder = [...$customOverlayOrder, layerId];
} }
} }
} }
function tryDeleteLayer(node: any, id: string): any { function tryDeleteLayer(node: any, id: string): any {
if (node.hasOwnProperty(id)) { if (node.hasOwnProperty(id)) {
delete node[id]; delete node[id];
} }
return node; return node;
} }
function deleteLayer(layerId: string) { function deleteLayer(layerId: string) {
let layer = $customLayers[layerId]; let layer = $customLayers[layerId];
if (layer.layerType === 'basemap') { if (layer.layerType === 'basemap') {
if (layerId === $currentBasemap) { if (layerId === $currentBasemap) {
$currentBasemap = defaultBasemap; $currentBasemap = defaultBasemap;
} }
if (layerId === $previousBasemap) { if (layerId === $previousBasemap) {
$previousBasemap = defaultBasemap; $previousBasemap = defaultBasemap;
} }
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer( $selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
$selectedBasemapTree.basemaps['custom'], $selectedBasemapTree.basemaps['custom'],
layerId layerId
); );
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) { if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
$selectedBasemapTree.basemaps = tryDeleteLayer($selectedBasemapTree.basemaps, 'custom'); $selectedBasemapTree.basemaps = tryDeleteLayer(
} $selectedBasemapTree.basemaps,
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId); 'custom'
} else { );
$currentOverlays.overlays['custom'][layerId] = false; }
if ($previousOverlays.overlays['custom']) { $customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
$previousOverlays.overlays['custom'] = tryDeleteLayer( } else {
$previousOverlays.overlays['custom'], $currentOverlays.overlays['custom'][layerId] = false;
layerId if ($previousOverlays.overlays['custom']) {
); $previousOverlays.overlays['custom'] = tryDeleteLayer(
} $previousOverlays.overlays['custom'],
layerId
);
}
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer( $selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
$selectedOverlayTree.overlays['custom'], $selectedOverlayTree.overlays['custom'],
layerId layerId
); );
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) { if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
$selectedOverlayTree.overlays = tryDeleteLayer($selectedOverlayTree.overlays, 'custom'); $selectedOverlayTree.overlays = tryDeleteLayer(
} $selectedOverlayTree.overlays,
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId); 'custom'
);
}
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
if ($map) { if (
if ($map.getLayer(layerId)) { $currentOverlays.overlays['custom'] &&
$map.removeLayer(layerId); $currentOverlays.overlays['custom'][layerId] &&
} $map
if ($map.getSource(layerId)) { ) {
$map.removeSource(layerId); 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);
}
let selectedLayerId: string | undefined = undefined; let selectedLayerId: string | undefined = undefined;
function setDataFromSelectedLayer() { function setDataFromSelectedLayer() {
if (selectedLayerId) { if (selectedLayerId) {
const layer = $customLayers[selectedLayerId]; const layer = $customLayers[selectedLayerId];
name = layer.name; name = layer.name;
tileUrls = layer.tileUrls; tileUrls = layer.tileUrls;
maxZoom = layer.maxZoom; maxZoom = layer.maxZoom;
layerType = layer.layerType; layerType = layer.layerType;
resourceType = layer.resourceType; resourceType = layer.resourceType;
} else { } else {
name = ''; name = '';
tileUrls = ['']; tileUrls = [''];
maxZoom = 20; maxZoom = 20;
layerType = 'basemap'; layerType = 'basemap';
resourceType = 'raster'; resourceType = 'raster';
} }
} }
$: selectedLayerId, setDataFromSelectedLayer(); $: selectedLayerId, setDataFromSelectedLayer();
</script> </script>
<div class="flex flex-col"> <div class="flex flex-col">
{#if $customBasemapOrder.length > 0} {#if $customBasemapOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2"> <div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Map size="16" /> <Map size="16" />
{$_('layers.label.basemaps')} {$_('layers.label.basemaps')}
<div class="grow"> <div class="grow">
<Separator /> <Separator />
</div> </div>
</div> </div>
{/if} {/if}
<div <div
bind:this={basemapContainer} bind:this={basemapContainer}
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}" class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
> >
{#each $customBasemapOrder as id (id)} {#each $customBasemapOrder as id (id)}
<div class="flex flex-row items-center gap-2" data-id={id}> <div class="flex flex-row items-center gap-2" data-id={id}>
<Move size="12" /> <Move size="12" />
<span class="grow">{$customLayers[id].name}</span> <span class="grow">{$customLayers[id].name}</span>
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7"> <Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
<Pencil size="16" /> <Pencil size="16" />
</Button> </Button>
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7"> <Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
<Trash2 size="16" /> <Trash2 size="16" />
</Button> </Button>
</div> </div>
{/each} {/each}
</div> </div>
{#if $customOverlayOrder.length > 0} {#if $customOverlayOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2"> <div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Layers2 size="16" /> <Layers2 size="16" />
{$_('layers.label.overlays')} {$_('layers.label.overlays')}
<div class="grow"> <div class="grow">
<Separator /> <Separator />
</div> </div>
</div> </div>
{/if} {/if}
<div <div
bind:this={overlayContainer} bind:this={overlayContainer}
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}" class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
> >
{#each $customOverlayOrder as id (id)} {#each $customOverlayOrder as id (id)}
<div class="flex flex-row items-center gap-2" data-id={id}> <div class="flex flex-row items-center gap-2" data-id={id}>
<Move size="12" /> <Move size="12" />
<span class="grow">{$customLayers[id].name}</span> <span class="grow">{$customLayers[id].name}</span>
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7"> <Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
<Pencil size="16" /> <Pencil size="16" />
</Button> </Button>
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7"> <Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
<Trash2 size="16" /> <Trash2 size="16" />
</Button> </Button>
</div> </div>
{/each} {/each}
</div> </div>
<Card.Root> <Card.Root>
<Card.Header class="p-3"> <Card.Header class="p-3">
<Card.Title class="text-base"> <Card.Title class="text-base">
{#if selectedLayerId} {#if selectedLayerId}
{$_('layers.custom_layers.edit')} {$_('layers.custom_layers.edit')}
{:else} {:else}
{$_('layers.custom_layers.new')} {$_('layers.custom_layers.new')}
{/if} {/if}
</Card.Title> </Card.Title>
</Card.Header> </Card.Header>
<Card.Content class="p-3 pt-0"> <Card.Content class="p-3 pt-0">
<fieldset class="flex flex-col gap-2"> <fieldset class="flex flex-col gap-2">
<Label for="name">{$_('menu.metadata.name')}</Label> <Label for="name">{$_('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="h-8" /> <Input bind:value={name} id="name" class="h-8" />
<Label for="url">{$_('layers.custom_layers.urls')}</Label> <Label for="url">{$_('layers.custom_layers.urls')}</Label>
{#each tileUrls as url, i} {#each tileUrls as url, i}
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<Input <Input
bind:value={tileUrls[i]} bind:value={tileUrls[i]}
id="url" id="url"
class="h-8" class="h-8"
placeholder={$_('layers.custom_layers.url_placeholder')} placeholder={$_('layers.custom_layers.url_placeholder')}
/> />
{#if tileUrls.length > 1} {#if tileUrls.length > 1}
<Button <Button
on:click={() => (tileUrls = tileUrls.filter((_, index) => index !== i))} on:click={() =>
variant="outline" (tileUrls = tileUrls.filter((_, index) => index !== i))}
class="p-1 h-8" variant="outline"
> class="p-1 h-8"
<Minus size="16" /> >
</Button> <Minus size="16" />
{/if} </Button>
{#if i === tileUrls.length - 1} {/if}
<Button {#if i === tileUrls.length - 1}
on:click={() => (tileUrls = [...tileUrls, ''])} <Button
variant="outline" on:click={() => (tileUrls = [...tileUrls, ''])}
class="p-1 h-8" variant="outline"
> class="p-1 h-8"
<Plus size="16" /> >
</Button> <Plus size="16" />
{/if} </Button>
</div> {/if}
{/each} </div>
{#if resourceType === 'raster'} {/each}
<Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label> {#if resourceType === 'raster'}
<Input type="number" bind:value={maxZoom} id="maxZoom" min={0} max={22} class="h-8" /> <Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
{/if} <Input
<Label>{$_('layers.custom_layers.layer_type')}</Label> type="number"
<RadioGroup.Root bind:value={layerType} class="flex flex-row"> bind:value={maxZoom}
<div class="flex items-center space-x-2"> id="maxZoom"
<RadioGroup.Item value="basemap" id="basemap" /> min={0}
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label> max={22}
</div> class="h-8"
<div class="flex items-center space-x-2"> />
<RadioGroup.Item value="overlay" id="overlay" disabled={resourceType === 'vector'} /> {/if}
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label> <Label>{$_('layers.custom_layers.layer_type')}</Label>
</div> <RadioGroup.Root bind:value={layerType} class="flex flex-row">
</RadioGroup.Root> <div class="flex items-center space-x-2">
{#if selectedLayerId} <RadioGroup.Item value="basemap" id="basemap" />
<div class="mt-2 flex flex-row gap-2"> <Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
<Button variant="outline" on:click={createLayer} class="grow"> </div>
<Save size="16" class="mr-1" /> <div class="flex items-center space-x-2">
{$_('layers.custom_layers.update')} <RadioGroup.Item value="overlay" id="overlay" />
</Button> <Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}> </div>
<CircleX size="16" /> </RadioGroup.Root>
</Button> {#if selectedLayerId}
</div> <div class="mt-2 flex flex-row gap-2">
{:else} <Button variant="outline" on:click={createLayer} class="grow">
<Button variant="outline" class="mt-2" on:click={createLayer}> <Save size="16" class="mr-1" />
<CirclePlus size="16" class="mr-1" /> {$_('layers.custom_layers.update')}
{$_('layers.custom_layers.create')} </Button>
</Button> <Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
{/if} <CircleX size="16" />
</fieldset> </Button>
</Card.Content> </div>
</Card.Root> {:else}
<Button variant="outline" class="mt-2" on:click={createLayer}>
<CirclePlus size="16" class="mr-1" />
{$_('layers.custom_layers.create')}
</Button>
{/if}
</fieldset>
</Card.Content>
</Card.Root>
</div> </div>

View File

@@ -11,7 +11,7 @@
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { map } from '$lib/stores'; import { map } from '$lib/stores';
import { get, writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
import { getLayers } from './utils'; import { customBasemapUpdate, getLayers } from './utils';
import { OverpassLayer } from './OverpassLayer'; import { OverpassLayer } from './OverpassLayer';
import OverpassPopup from './OverpassPopup.svelte'; import OverpassPopup from './OverpassPopup.svelte';
@@ -35,33 +35,80 @@
let basemap = basemaps.hasOwnProperty($currentBasemap) let basemap = basemaps.hasOwnProperty($currentBasemap)
? basemaps[$currentBasemap] ? basemaps[$currentBasemap]
: $customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]; : $customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap];
$map.setStyle(basemap, { $map.removeImport('basemap');
diff: false if (typeof basemap === 'string') {
}); $map.addImport({ id: 'basemap', url: basemap }, 'overlays');
} else {
$map.addImport(
{
id: 'basemap',
data: basemap
},
'overlays'
);
}
} }
} }
$: if ($map && $currentBasemap) { $: if ($map && ($currentBasemap || $customBasemapUpdate)) {
setStyle(); setStyle();
} }
$: if ($map && $currentOverlays) { function addOverlay(id: string) {
// Add or remove overlay layers depending on the current overlays try {
let overlayLayers = getLayers($currentOverlays); let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
Object.keys(overlayLayers).forEach((id) => { if (typeof overlay === 'string') {
if (overlayLayers[id]) { $map.addImport({ id, url: overlay });
if (!addOverlayLayer.hasOwnProperty(id)) { } else {
addOverlayLayer[id] = addOverlayLayerForId(id); if ($opacities.hasOwnProperty(id)) {
overlay = {
...overlay,
layers: overlay.layers.map((layer) => {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = $opacities[id];
}
return layer;
})
};
} }
if (!$map.getLayer(id)) { $map.addImport({
addOverlayLayer[id](); id,
$map.on('style.load', addOverlayLayer[id]); data: overlay
} });
} else if ($map.getLayer(id)) {
$map.removeLayer(id);
$map.off('style.load', addOverlayLayer[id]);
} }
}); } catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
function updateOverlays() {
if ($map && $currentOverlays) {
let overlayLayers = getLayers($currentOverlays);
try {
let activeOverlays = $map
.getStyle()
.imports.filter((i) => i.id !== 'basemap' && i.id !== 'overlays');
let toRemove = activeOverlays.filter((i) => !overlayLayers[i.id]);
toRemove.forEach((i) => {
$map.removeImport(i.id);
});
let toAdd = Object.entries(overlayLayers)
.filter(([id, selected]) => selected && !activeOverlays.some((j) => j.id === id))
.map(([id]) => id);
toAdd.forEach((id) => {
addOverlay(id);
});
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
}
$: if ($map && $currentOverlays) {
updateOverlays();
} }
$: if ($map) { $: if ($map) {
@@ -70,6 +117,7 @@
} }
overpassLayer = new OverpassLayer($map); overpassLayer = new OverpassLayer($map);
overpassLayer.add(); overpassLayer.add();
$map.on('style.import.load', updateOverlays);
} }
let selectedBasemap = writable(get(currentBasemap)); let selectedBasemap = writable(get(currentBasemap));
@@ -85,37 +133,6 @@
selectedBasemap.set(value); selectedBasemap.set(value);
}); });
let addOverlayLayer: { [key: string]: () => void } = {};
function addOverlayLayerForId(id: string) {
return () => {
if ($map) {
try {
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
if (!$map.getSource(id)) {
$map.addSource(id, overlay);
}
$map.addLayer(
{
id,
type: overlay.type === 'raster' ? 'raster' : 'line',
source: id,
paint: {
...(id in $opacities
? overlay.type === 'raster'
? { 'raster-opacity': $opacities[id] }
: { 'line-opacity': $opacities[id] }
: {})
}
},
'overlays'
);
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
};
}
let open = false; let open = false;
function openLayerControl() { function openLayerControl() {
open = true; open = true;

View File

@@ -9,8 +9,14 @@
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import { Slider } from '$lib/components/ui/slider'; import { Slider } from '$lib/components/ui/slider';
import { basemapTree, overlays, overlayTree, overpassTree } from '$lib/assets/layers'; import {
import { isSelected } from '$lib/components/layer-control/utils'; basemapTree,
defaultBasemap,
overlays,
overlayTree,
overpassTree
} from '$lib/assets/layers';
import { getLayers, isSelected, toggle } from '$lib/components/layer-control/utils';
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
@@ -22,6 +28,7 @@
selectedBasemapTree, selectedBasemapTree,
selectedOverlayTree, selectedOverlayTree,
selectedOverpassTree, selectedOverpassTree,
currentBasemap,
currentOverlays, currentOverlays,
customLayers, customLayers,
opacities opacities
@@ -46,6 +53,30 @@
} }
} }
$: if ($selectedBasemapTree && $currentBasemap) {
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
}
$currentBasemap = defaultBasemap;
}
}
$: if ($selectedOverlayTree && $currentOverlays) {
let overlayLayers = getLayers($currentOverlays);
let toRemove = Object.entries(overlayLayers).filter(
([id, checked]) => checked && !isSelected($selectedOverlayTree, id)
);
if (toRemove.length > 0) {
currentOverlays.update((tree) => {
toRemove.forEach(([id]) => {
toggle(tree, id);
});
return tree;
});
}
}
$: if ($selectedOverlay) { $: if ($selectedOverlay) {
setOpacityFromSelection(); setOpacityFromSelection();
} }

View File

@@ -46,6 +46,7 @@
value={id} value={id}
bind:checked={checked[id]} bind:checked={checked[id]}
class="scale-90" class="scale-90"
aria-label={$_(`layers.label.${id}`)}
/> />
{:else} {:else}
<input id="{name}-{id}" type="radio" {name} value={id} bind:group={selected} /> <input id="{name}-{id}" type="radio" {name} value={id} bind:group={selected} />

View File

@@ -50,7 +50,7 @@ export class OverpassLayer {
add() { add() {
this.map.on('moveend', this.queryIfNeededBinded); this.map.on('moveend', this.queryIfNeededBinded);
this.map.on('style.load', this.updateBinded); this.map.on('style.import.load', this.updateBinded);
this.unsubscribes.push(data.subscribe(this.updateBinded)); this.unsubscribes.push(data.subscribe(this.updateBinded));
this.unsubscribes.push(currentOverpassQueries.subscribe(() => { this.unsubscribes.push(currentOverpassQueries.subscribe(() => {
this.updateBinded(); this.updateBinded();
@@ -108,15 +108,19 @@ export class OverpassLayer {
remove() { remove() {
this.map.off('moveend', this.queryIfNeededBinded); this.map.off('moveend', this.queryIfNeededBinded);
this.map.off('style.load', this.updateBinded); this.map.off('style.import.load', this.updateBinded);
this.unsubscribes.forEach((unsubscribe) => unsubscribe()); this.unsubscribes.forEach((unsubscribe) => unsubscribe());
if (this.map.getLayer('overpass')) { try {
this.map.removeLayer('overpass'); if (this.map.getLayer('overpass')) {
} this.map.removeLayer('overpass');
}
if (this.map.getSource('overpass')) { if (this.map.getSource('overpass')) {
this.map.removeSource('overpass'); this.map.removeSource('overpass');
}
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
} }
} }

View File

@@ -62,11 +62,11 @@
{#if key !== 'name' && !key.includes('image')} {#if key !== 'name' && !key.includes('image')}
<span class="font-mono">{key}</span> <span class="font-mono">{key}</span>
{#if key === 'website' || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'} {#if key === 'website' || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
<a href={value} target="_blank" class="text-blue-500 underline">{value}</a> <a href={value} target="_blank" class="text-link underline">{value}</a>
{:else if key === 'phone' || key === 'contact:phone'} {:else if key === 'phone' || key === 'contact:phone'}
<a href={'tel:' + value} class="text-blue-500 underline">{value}</a> <a href={'tel:' + value} class="text-link underline">{value}</a>
{:else if key === 'email' || key === 'contact:email'} {:else if key === 'email' || key === 'contact:email'}
<a href={'mailto:' + value} class="text-blue-500 underline">{value}</a> <a href={'mailto:' + value} class="text-link underline">{value}</a>
{:else} {:else}
<span>{value}</span> <span>{value}</span>
{/if} {/if}

View File

@@ -1,4 +1,5 @@
import type { LayerTreeType } from "$lib/assets/layers"; import type { LayerTreeType } from "$lib/assets/layers";
import { writable } from "svelte/store";
export function anySelectedLayer(node: LayerTreeType) { export function anySelectedLayer(node: LayerTreeType) {
return Object.keys(node).find((id) => { return Object.keys(node).find((id) => {
@@ -36,4 +37,17 @@ export function isSelected(node: LayerTreeType, id: string) {
} }
return false; return false;
}); });
} }
export function toggle(node: LayerTreeType, id: string) {
Object.keys(node).forEach((key) => {
if (key === id) {
node[key] = !node[key];
} else if (typeof node[key] !== "boolean") {
toggle(node[key], id);
}
});
return node;
}
export const customBasemapUpdate = writable(0);

View File

@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import CustomControl from '$lib/components/custom-control/CustomControl.svelte'; import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import { Toggle } from '$lib/components/ui/toggle'; import { Toggle } from '$lib/components/ui/toggle';
import { PersonStanding, X } from 'lucide-svelte'; import { PersonStanding, X } from 'lucide-svelte';
import { MapillaryLayer } from './Mapillary'; import { MapillaryLayer } from './Mapillary';
import { GoogleRedirect } from './Google'; import { GoogleRedirect } from './Google';
import { map, streetViewEnabled } from '$lib/stores'; import { map, streetViewEnabled } from '$lib/stores';
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { _ } from 'svelte-i18n';
const { streetViewSource } = settings; const { streetViewSource } = settings;
@@ -38,9 +40,15 @@
</script> </script>
<CustomControl class="w-[29px] h-[29px] shrink-0"> <CustomControl class="w-[29px] h-[29px] shrink-0">
<Toggle bind:pressed={$streetViewEnabled} class="w-full h-full rounded p-0"> <Tooltip class="w-full h-full" side="left" label={$_('menu.toggle_street_view')}>
<PersonStanding size="22" /> <Toggle
</Toggle> bind:pressed={$streetViewEnabled}
class="w-full h-full rounded p-0"
aria-label={$_('menu.toggle_street_view')}
>
<PersonStanding size="22" />
</Toggle>
</Tooltip>
</CustomControl> </CustomControl>
<div <div

View File

@@ -9,7 +9,8 @@
Ungroup, Ungroup,
MapPin, MapPin,
Filter, Filter,
Scissors Scissors,
MountainSnow
} from 'lucide-svelte'; } from 'lucide-svelte';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
@@ -21,37 +22,32 @@
class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {$$props.class ?? class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {$$props.class ??
''}" ''}"
> >
<ToolbarItem tool={Tool.ROUTING}> <ToolbarItem tool={Tool.ROUTING} label={$_('toolbar.routing.tooltip')}>
<Pencil slot="icon" size="18" class="h-" /> <Pencil slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.routing.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
<ToolbarItem tool={Tool.WAYPOINT}> <ToolbarItem tool={Tool.WAYPOINT} label={$_('toolbar.waypoint.tooltip')}>
<MapPin slot="icon" size="18" /> <MapPin slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.waypoint.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
<ToolbarItem tool={Tool.SCISSORS}> <ToolbarItem tool={Tool.SCISSORS} label={$_('toolbar.scissors.tooltip')}>
<Scissors slot="icon" size="18" /> <Scissors slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.scissors.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
<ToolbarItem tool={Tool.TIME}> <ToolbarItem tool={Tool.TIME} label={$_('toolbar.time.tooltip')}>
<CalendarClock slot="icon" size="18" /> <CalendarClock slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.time.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
<ToolbarItem tool={Tool.MERGE}> <ToolbarItem tool={Tool.MERGE} label={$_('toolbar.merge.tooltip')}>
<Group slot="icon" size="18" /> <Group slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.merge.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
<ToolbarItem tool={Tool.EXTRACT}> <ToolbarItem tool={Tool.EXTRACT} label={$_('toolbar.extract.tooltip')}>
<Ungroup slot="icon" size="18" /> <Ungroup slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.extract.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
<ToolbarItem tool={Tool.REDUCE}> <ToolbarItem tool={Tool.ELEVATION} label={$_('toolbar.elevation.button')}>
<MountainSnow slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.REDUCE} label={$_('toolbar.reduce.tooltip')}>
<Filter slot="icon" size="18" /> <Filter slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.reduce.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
<ToolbarItem tool={Tool.CLEAN}> <ToolbarItem tool={Tool.CLEAN} label={$_('toolbar.clean.tooltip')}>
<SquareDashedMousePointer slot="icon" size="18" /> <SquareDashedMousePointer slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.clean.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
</div> </div>
<ToolbarItemMenu class={$$props.class ?? ''} /> <ToolbarItemMenu class={$$props.class ?? ''} />

View File

@@ -4,6 +4,7 @@
import { currentTool, type Tool } from '$lib/stores'; import { currentTool, type Tool } from '$lib/stores';
export let tool: Tool; export let tool: Tool;
export let label: string;
function toggleTool() { function toggleTool() {
currentTool.update((current) => (current === tool ? null : tool)); currentTool.update((current) => (current === tool ? null : tool));
@@ -17,11 +18,12 @@
variant="ghost" variant="ghost"
class="h-[26px] px-1 py-1.5 {$currentTool === tool ? 'bg-accent' : ''}" class="h-[26px] px-1 py-1.5 {$currentTool === tool ? 'bg-accent' : ''}"
on:click={toggleTool} on:click={toggleTool}
aria-label={label}
> >
<slot name="icon" /> <slot name="icon" />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side="right"> <Tooltip.Content side="right">
<slot name="tooltip" /> <span>{label}</span>
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>

View File

@@ -9,6 +9,7 @@
import Time from '$lib/components/toolbar/tools/Time.svelte'; import Time from '$lib/components/toolbar/tools/Time.svelte';
import Merge from '$lib/components/toolbar/tools/Merge.svelte'; import Merge from '$lib/components/toolbar/tools/Merge.svelte';
import Extract from '$lib/components/toolbar/tools/Extract.svelte'; import Extract from '$lib/components/toolbar/tools/Extract.svelte';
import Elevation from '$lib/components/toolbar/tools/Elevation.svelte';
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.svelte'; import Reduce from '$lib/components/toolbar/tools/Reduce.svelte';
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte'; import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
@@ -48,6 +49,8 @@
<Time /> <Time />
{:else if $currentTool === Tool.MERGE} {:else if $currentTool === Tool.MERGE}
<Merge /> <Merge />
{:else if $currentTool === Tool.ELEVATION}
<Elevation />
{:else if $currentTool === Tool.EXTRACT} {:else if $currentTool === Tool.EXTRACT}
<Extract /> <Extract />
{:else if $currentTool === Tool.CLEAN} {:else if $currentTool === Tool.CLEAN}

View File

@@ -11,9 +11,9 @@
import * as RadioGroup from '$lib/components/ui/radio-group'; import * as RadioGroup from '$lib/components/ui/radio-group';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { _ } from 'svelte-i18n'; import { _, locale } from 'svelte-i18n';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { resetCursor, setCrosshairCursor } from '$lib/utils'; import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
import { Trash2 } from 'lucide-svelte'; import { Trash2 } from 'lucide-svelte';
import { map } from '$lib/stores'; import { map } from '$lib/stores';
import { selection } from '$lib/components/file-list/Selection'; import { selection } from '$lib/components/file-list/Selection';
@@ -178,7 +178,7 @@
<Trash2 size="16" class="mr-1" /> <Trash2 size="16" class="mr-1" />
{$_('toolbar.clean.button')} {$_('toolbar.clean.button')}
</Button> </Button>
<Help link="./help/toolbar/clean"> <Help link={getURLForLanguage($locale, '/help/toolbar/clean')}>
{#if validSelection} {#if validSelection}
{$_('toolbar.clean.help')} {$_('toolbar.clean.help')}
{:else} {:else}

View File

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

View File

@@ -11,7 +11,8 @@
} from '$lib/components/file-list/FileList'; } from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { dbUtils, getFile } from '$lib/db'; import { dbUtils, getFile } from '$lib/db';
import { _ } from 'svelte-i18n'; import { _, locale } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils';
$: validSelection = $: validSelection =
$selection.size > 0 && $selection.size > 0 &&
@@ -42,7 +43,7 @@
<Ungroup size="16" class="mr-1" /> <Ungroup size="16" class="mr-1" />
{$_('toolbar.extract.button')} {$_('toolbar.extract.button')}
</Button> </Button>
<Help link="./help/toolbar/extract"> <Help link={getURLForLanguage($locale, '/help/toolbar/extract')}>
{#if validSelection} {#if validSelection}
{$_('toolbar.extract.help')} {$_('toolbar.extract.help')}
{:else} {:else}

View File

@@ -12,9 +12,11 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
import * as RadioGroup from '$lib/components/ui/radio-group'; import * as RadioGroup from '$lib/components/ui/radio-group';
import { _ } from 'svelte-i18n'; import { _, locale } from 'svelte-i18n';
import { dbUtils, getFile } from '$lib/db'; import { dbUtils, getFile } from '$lib/db';
import { Group } from 'lucide-svelte'; import { Group } from 'lucide-svelte';
import { getURLForLanguage } from '$lib/utils';
import Shortcut from '$lib/components/Shortcut.svelte';
let canMergeTraces = false; let canMergeTraces = false;
let canMergeContents = false; let canMergeContents = false;
@@ -65,24 +67,39 @@
</RadioGroup.Root> </RadioGroup.Root>
<Button <Button
variant="outline" variant="outline"
class="whitespace-normal h-fit"
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) || disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
(mergeType === MergeType.CONTENTS && !canMergeContents)} (mergeType === MergeType.CONTENTS && !canMergeContents)}
on:click={() => { on:click={() => {
dbUtils.mergeSelection(mergeType === MergeType.TRACES); dbUtils.mergeSelection(mergeType === MergeType.TRACES);
}} }}
> >
<Group size="16" class="mr-1" /> <Group size="16" class="mr-1 shrink-0" />
{$_('toolbar.merge.merge_selection')} {$_('toolbar.merge.merge_selection')}
</Button> </Button>
<Help link="./help/toolbar/merge"> <Help link={getURLForLanguage($locale, '/help/toolbar/merge')}>
{#if mergeType === MergeType.TRACES && canMergeTraces} {#if mergeType === MergeType.TRACES && canMergeTraces}
{$_('toolbar.merge.help_merge_traces')} {$_('toolbar.merge.help_merge_traces')}
{:else if mergeType === MergeType.TRACES && !canMergeTraces} {:else if mergeType === MergeType.TRACES && !canMergeTraces}
{$_('toolbar.merge.help_cannot_merge_traces')} {$_('toolbar.merge.help_cannot_merge_traces')}
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
<Shortcut
ctrl={true}
click={true}
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
/>
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
{:else if mergeType === MergeType.CONTENTS && canMergeContents} {:else if mergeType === MergeType.CONTENTS && canMergeContents}
{$_('toolbar.merge.help_merge_contents')} {$_('toolbar.merge.help_merge_contents')}
{:else if mergeType === MergeType.CONTENTS && !canMergeContents} {:else if mergeType === MergeType.CONTENTS && !canMergeContents}
{$_('toolbar.merge.help_cannot_merge_contents')} {$_('toolbar.merge.help_cannot_merge_contents')}
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
<Shortcut
ctrl={true}
click={true}
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
/>
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
{/if} {/if}
</Help> </Help>
</div> </div>

View File

@@ -6,13 +6,14 @@
import { ListItem, ListRootItem, ListTrackSegmentItem } from '$lib/components/file-list/FileList'; import { ListItem, ListRootItem, ListTrackSegmentItem } from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { Filter } from 'lucide-svelte'; import { Filter } from 'lucide-svelte';
import { _ } from 'svelte-i18n'; import { _, locale } from 'svelte-i18n';
import WithUnits from '$lib/components/WithUnits.svelte'; import WithUnits from '$lib/components/WithUnits.svelte';
import { dbUtils, fileObservers } from '$lib/db'; import { dbUtils, fileObservers } from '$lib/db';
import { map } from '$lib/stores'; import { map } from '$lib/stores';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx'; import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
import { derived } from 'svelte/store'; import { derived } from 'svelte/store';
import { getURLForLanguage } from '$lib/utils';
let sliderValue = [50]; let sliderValue = [50];
let maxPoints = 0; let maxPoints = 0;
@@ -153,18 +154,18 @@
</div> </div>
<Label class="flex flex-row justify-between"> <Label class="flex flex-row justify-between">
<span>{$_('toolbar.reduce.tolerance')}</span> <span>{$_('toolbar.reduce.tolerance')}</span>
<WithUnits value={tolerance / 1000} type="distance" decimals={3} /> <WithUnits value={tolerance / 1000} type="distance" decimals={3} class="font-normal" />
</Label> </Label>
<Label class="flex flex-row justify-between"> <Label class="flex flex-row justify-between">
<span>{$_('toolbar.reduce.number_of_points')}</span> <span>{$_('toolbar.reduce.number_of_points')}</span>
<span>{currentPoints}/{maxPoints}</span> <span class="font-normal">{currentPoints}/{maxPoints}</span>
</Label> </Label>
<Button variant="outline" disabled={!validSelection} on:click={reduce}> <Button variant="outline" disabled={!validSelection} on:click={reduce}>
<Filter size="16" class="mr-1" /> <Filter size="16" class="mr-1" />
{$_('toolbar.reduce.button')} {$_('toolbar.reduce.button')}
</Button> </Button>
<Help link="./help/toolbar/minify"> <Help link={getURLForLanguage($locale, '/help/toolbar/minify')}>
{#if validSelection} {#if validSelection}
{$_('toolbar.reduce.help')} {$_('toolbar.reduce.help')}
{:else} {:else}

View File

@@ -10,7 +10,8 @@
import { import {
distancePerHourToSecondsPerDistance, distancePerHourToSecondsPerDistance,
getConvertedVelocity, getConvertedVelocity,
milesToKilometers milesToKilometers,
nauticalMilesToKilometers
} from '$lib/units'; } from '$lib/units';
import { CalendarDate, type DateValue } from '@internationalized/date'; import { CalendarDate, type DateValue } from '@internationalized/date';
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte'; import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
@@ -25,6 +26,7 @@
ListTrackSegmentItem ListTrackSegmentItem
} from '$lib/components/file-list/FileList'; } from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { getURLForLanguage } from '$lib/utils';
let startDate: DateValue | undefined = undefined; let startDate: DateValue | undefined = undefined;
let startTime: string | undefined = undefined; let startTime: string | undefined = undefined;
@@ -32,11 +34,16 @@
let endTime: string | undefined = undefined; let endTime: string | undefined = undefined;
let movingTime: number | undefined = undefined; let movingTime: number | undefined = undefined;
let speed: number | undefined = undefined; let speed: number | undefined = undefined;
let artificial = false;
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());
} }
function toTimeString(date: Date): string {
return date.toTimeString().split(' ')[0];
}
const { velocityUnits, distanceUnits } = settings; const { velocityUnits, distanceUnits } = settings;
function setSpeed(value: number) { function setSpeed(value: number) {
@@ -50,14 +57,14 @@
function setGPXData() { function setGPXData() {
if ($gpxStatistics.global.time.start) { if ($gpxStatistics.global.time.start) {
startDate = toCalendarDate($gpxStatistics.global.time.start); startDate = toCalendarDate($gpxStatistics.global.time.start);
startTime = $gpxStatistics.global.time.start.toLocaleTimeString(); startTime = toTimeString($gpxStatistics.global.time.start);
} else { } else {
startDate = undefined; startDate = undefined;
startTime = undefined; startTime = undefined;
} }
if ($gpxStatistics.global.time.end) { if ($gpxStatistics.global.time.end) {
endDate = toCalendarDate($gpxStatistics.global.time.end); endDate = toCalendarDate($gpxStatistics.global.time.end);
endTime = $gpxStatistics.global.time.end.toLocaleTimeString(); endTime = toTimeString($gpxStatistics.global.time.end);
} else { } else {
endDate = undefined; endDate = undefined;
endTime = undefined; endTime = undefined;
@@ -83,6 +90,9 @@
return new Date(); return new Date();
} }
let [hours, minutes, seconds] = time.split(':').map((x) => parseInt(x)); let [hours, minutes, seconds] = time.split(':').map((x) => parseInt(x));
if (seconds === undefined) {
seconds = 0;
}
return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds); return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds);
} }
@@ -98,7 +108,7 @@
: 1; : 1;
let end = new Date(start.getTime() + ratio * movingTime * 1000); let end = new Date(start.getTime() + ratio * movingTime * 1000);
endDate = toCalendarDate(end); endDate = toCalendarDate(end);
endTime = end.toLocaleTimeString(); endTime = toTimeString(end);
} }
} }
@@ -114,7 +124,7 @@
: 1; : 1;
let start = new Date(end.getTime() - ratio * movingTime * 1000); let start = new Date(end.getTime() - ratio * movingTime * 1000);
startDate = toCalendarDate(start); startDate = toCalendarDate(start);
startTime = start.toLocaleTimeString(); startTime = toTimeString(start);
} }
} }
@@ -129,6 +139,8 @@
} }
if ($distanceUnits === 'imperial') { if ($distanceUnits === 'imperial') {
speedValue = milesToKilometers(speedValue); speedValue = milesToKilometers(speedValue);
} else if ($distanceUnits === 'nautical') {
speedValue = nauticalMilesToKilometers(speedValue);
} }
return speedValue; return speedValue;
} }
@@ -190,8 +202,10 @@
<span class="text-sm shrink-0"> <span class="text-sm shrink-0">
{#if $distanceUnits === 'imperial'} {#if $distanceUnits === 'imperial'}
{$_('units.miles_per_hour')} {$_('units.miles_per_hour')}
{:else} {:else if $distanceUnits === 'metric'}
{$_('units.kilometers_per_hour')} {$_('units.kilometers_per_hour')}
{:else if $distanceUnits === 'nautical'}
{$_('units.knots')}
{/if} {/if}
</span> </span>
{:else} {:else}
@@ -204,8 +218,10 @@
<span class="text-sm shrink-0"> <span class="text-sm shrink-0">
{#if $distanceUnits === 'imperial'} {#if $distanceUnits === 'imperial'}
{$_('units.minutes_per_mile')} {$_('units.minutes_per_mile')}
{:else} {:else if $distanceUnits === 'metric'}
{$_('units.minutes_per_kilometer')} {$_('units.minutes_per_kilometer')}
{:else if $distanceUnits === 'nautical'}
{$_('units.minutes_per_nautical_mile')}
{/if} {/if}
</span> </span>
{/if} {/if}
@@ -274,19 +290,19 @@
/> />
</div> </div>
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined} {#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
<div class="mt-0.5 flex flex-row gap-1 items-center hidden"> <div class="mt-0.5 flex flex-row gap-1 items-center">
<Checkbox id="artificial-time" disabled={!canUpdate} /> <Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} />
<Label for="artificial-time"> <Label for="artificial-time">
{$_('toolbar.time.artificial')} {$_('toolbar.time.artificial')}
</Label> </Label>
</div> </div>
{/if} {/if}
</fieldset> </fieldset>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2 items-center">
<Button <Button
variant="outline" variant="outline"
disabled={!canUpdate} disabled={!canUpdate}
class="grow" class="grow whitespace-normal h-fit"
on:click={() => { on:click={() => {
let effectiveSpeed = getSpeed(); let effectiveSpeed = getSpeed();
if (startDate === undefined || startTime === undefined || effectiveSpeed === undefined) { if (startDate === undefined || startTime === undefined || effectiveSpeed === undefined) {
@@ -309,34 +325,55 @@
let fileId = item.getFileId(); let fileId = item.getFileId();
dbUtils.applyToFile(fileId, (file) => { dbUtils.applyToFile(fileId, (file) => {
if (item instanceof ListFileItem) { if (item instanceof ListFileItem) {
file.changeTimestamps(getDate(startDate, startTime), effectiveSpeed, ratio); if (artificial) {
file.createArtificialTimestamps(getDate(startDate, startTime), movingTime);
} else {
file.changeTimestamps(getDate(startDate, startTime), effectiveSpeed, ratio);
}
} else if (item instanceof ListTrackItem) { } else if (item instanceof ListTrackItem) {
file.changeTimestamps( if (artificial) {
getDate(startDate, startTime), file.createArtificialTimestamps(
effectiveSpeed, getDate(startDate, startTime),
ratio, movingTime,
item.getTrackIndex() item.getTrackIndex()
); );
} else {
file.changeTimestamps(
getDate(startDate, startTime),
effectiveSpeed,
ratio,
item.getTrackIndex()
);
}
} else if (item instanceof ListTrackSegmentItem) { } else if (item instanceof ListTrackSegmentItem) {
file.changeTimestamps( if (artificial) {
getDate(startDate, startTime), file.createArtificialTimestamps(
effectiveSpeed, getDate(startDate, startTime),
ratio, movingTime,
item.getTrackIndex(), item.getTrackIndex(),
item.getSegmentIndex() item.getSegmentIndex()
); );
} else {
file.changeTimestamps(
getDate(startDate, startTime),
effectiveSpeed,
ratio,
item.getTrackIndex(),
item.getSegmentIndex()
);
}
} }
}); });
}} }}
> >
<CalendarClock size="16" class="mr-1" /> <CalendarClock size="16" class="mr-1 shrink-0" />
{$_('toolbar.time.update')} {$_('toolbar.time.update')}
</Button> </Button>
<Button variant="outline" on:click={setGPXData}> <Button variant="outline" on:click={setGPXData}>
<CircleX size="16" /> <CircleX size="16" />
</Button> </Button>
</div> </div>
<Help link="./help/toolbar/time"> <Help link={getURLForLanguage($locale, '/help/toolbar/time')}>
{#if canUpdate} {#if canUpdate}
{$_('toolbar.time.help')} {$_('toolbar.time.help')}
{:else} {:else}

View File

@@ -19,8 +19,8 @@
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { map } from '$lib/stores'; import { map } from '$lib/stores';
import { resetCursor, setCrosshairCursor } from '$lib/utils'; import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
import { CirclePlus, CircleX, Save } from 'lucide-svelte'; import { MapPin, CircleX, Save } from 'lucide-svelte';
import { getSymbolKey, symbols } from '$lib/assets/symbols'; import { getSymbolKey, symbols } from '$lib/assets/symbols';
let name: string; let name: string;
@@ -181,12 +181,21 @@
<div class="flex flex-col gap-3 w-full max-w-96 {$$props.class ?? ''}"> <div class="flex flex-col gap-3 w-full max-w-96 {$$props.class ?? ''}">
<fieldset class="flex flex-col gap-2"> <fieldset class="flex flex-col gap-2">
<Label for="name">{$_('menu.metadata.name')}</Label> <Label for="name">{$_('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="font-semibold h-8" /> <Input
bind:value={name}
id="name"
class="font-semibold h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
<Label for="description">{$_('menu.metadata.description')}</Label> <Label for="description">{$_('menu.metadata.description')}</Label>
<Textarea bind:value={description} id="description" /> <Textarea
bind:value={description}
id="description"
disabled={!canCreate && !$selectedWaypoint}
/>
<Label for="symbol">{$_('toolbar.waypoint.icon')}</Label> <Label for="symbol">{$_('toolbar.waypoint.icon')}</Label>
<Select.Root bind:selected={selectedSymbol}> <Select.Root bind:selected={selectedSymbol}>
<Select.Trigger id="symbol" class="w-full h-8"> <Select.Trigger id="symbol" class="w-full h-8" disabled={!canCreate && !$selectedWaypoint}>
<Select.Value /> <Select.Value />
</Select.Trigger> </Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll"> <Select.Content class="max-h-60 overflow-y-scroll">
@@ -209,9 +218,9 @@
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
<Label for="link">{$_('toolbar.waypoint.link')}</Label> <Label for="link">{$_('toolbar.waypoint.link')}</Label>
<Input bind:value={link} id="link" class="h-8" /> <Input bind:value={link} id="link" class="h-8" disabled={!canCreate && !$selectedWaypoint} />
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<div> <div class="grow">
<Label for="latitude">{$_('toolbar.waypoint.latitude')}</Label> <Label for="latitude">{$_('toolbar.waypoint.latitude')}</Label>
<Input <Input
bind:value={latitude} bind:value={latitude}
@@ -221,9 +230,10 @@
min={-90} min={-90}
max={90} max={90}
class="text-xs h-8" class="text-xs h-8"
disabled={!canCreate && !$selectedWaypoint}
/> />
</div> </div>
<div> <div class="grow">
<Label for="longitude">{$_('toolbar.waypoint.longitude')}</Label> <Label for="longitude">{$_('toolbar.waypoint.longitude')}</Label>
<Input <Input
bind:value={longitude} bind:value={longitude}
@@ -233,28 +243,28 @@
min={-180} min={-180}
max={180} max={180}
class="text-xs h-8" class="text-xs h-8"
disabled={!canCreate && !$selectedWaypoint}
/> />
</div> </div>
</div> </div>
</fieldset> </fieldset>
<div class="flex flex-row flex-wrap gap-2"> <div class="flex flex-row gap-2 items-center">
<Button <Button
variant="outline" variant="outline"
disabled={!canCreate && !$selectedWaypoint} disabled={!canCreate && !$selectedWaypoint}
class="grow" class="grow whitespace-normal h-fit"
on:click={createOrUpdateWaypoint} on:click={createOrUpdateWaypoint}
> >
{#if $selectedWaypoint} {#if $selectedWaypoint}
<Save size="16" class="mr-1" /> <Save size="16" class="mr-1 shrink-0" />
{$_('menu.metadata.save')} {$_('menu.metadata.save')}
{:else} {:else}
<CirclePlus size="16" class="mr-1" /> <MapPin size="16" class="mr-1 shrink-0" />
{$_('toolbar.waypoint.create')} {$_('toolbar.waypoint.create')}
{/if} {/if}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
class="ml-auto"
on:click={() => { on:click={() => {
selectedWaypoint.set(undefined); selectedWaypoint.set(undefined);
resetWaypointData(); resetWaypointData();
@@ -263,7 +273,7 @@
<CircleX size="16" /> <CircleX size="16" />
</Button> </Button>
</div> </div>
<Help link="./help/toolbar/poi"> <Help link={getURLForLanguage($locale, '/help/toolbar/poi')}>
{#if $selectedWaypoint || canCreate} {#if $selectedWaypoint || canCreate}
{$_('toolbar.waypoint.help')} {$_('toolbar.waypoint.help')}
{:else} {:else}

View File

@@ -4,6 +4,7 @@
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
import Tooltip from '$lib/components/Tooltip.svelte'; import Tooltip from '$lib/components/Tooltip.svelte';
import Shortcut from '$lib/components/Shortcut.svelte'; import Shortcut from '$lib/components/Shortcut.svelte';
import { import {
@@ -25,7 +26,7 @@
import { dbUtils, getFile, getFileIds, settings } from '$lib/db'; import { dbUtils, getFile, getFileIds, settings } from '$lib/db';
import { brouterProfiles, routingProfileSelectItem } from './Routing'; import { brouterProfiles, routingProfileSelectItem } from './Routing';
import { _ } from 'svelte-i18n'; import { _, locale } from 'svelte-i18n';
import { RoutingControls } from './RoutingControls'; import { RoutingControls } from './RoutingControls';
import mapboxgl from 'mapbox-gl'; import mapboxgl from 'mapbox-gl';
import { fileObservers } from '$lib/db'; import { fileObservers } from '$lib/db';
@@ -38,7 +39,7 @@
ListTrackSegmentItem, ListTrackSegmentItem,
type ListItem type ListItem
} from '$lib/components/file-list/FileList'; } from '$lib/components/file-list/FileList';
import { flyAndScale, resetCursor, setCrosshairCursor } from '$lib/utils'; import { flyAndScale, getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { TrackPoint } from 'gpx'; import { TrackPoint } from 'gpx';
@@ -105,7 +106,7 @@
}); });
</script> </script>
{#if minimized} {#if minimizable && minimized}
<div class="-m-1.5 -mb-2"> <div class="-m-1.5 -mb-2">
<Button variant="ghost" class="px-1 h-[26px]" on:click={() => (minimized = false)}> <Button variant="ghost" class="px-1 h-[26px]" on:click={() => (minimized = false)}>
<SquareArrowOutDownRight size="18" /> <SquareArrowOutDownRight size="18" />
@@ -116,27 +117,24 @@
class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}" class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}"
in:flyAndScale={{ x: -2, y: 0, duration: 50 }} in:flyAndScale={{ x: -2, y: 0, duration: 50 }}
> >
<div class="grow flex flex-col gap-3"> <div class="flex flex-col gap-3">
<Tooltip> <Label class="flex flex-row justify-between items-center gap-2">
<Label slot="data" class="w-full flex flex-row justify-between items-center gap-2"> <span class="flex flex-row items-center gap-1">
<span class="flex flex-row gap-1"> {#if $routing}
{#if $routing} <Route size="16" />
<Route size="16" /> {:else}
{:else} <RouteOff size="16" />
<RouteOff size="16" /> {/if}
{/if} {$_('toolbar.routing.use_routing')}
{$_('toolbar.routing.use_routing')}
</span>
<Switch class="scale-90" bind:checked={$routing} />
</Label>
<span slot="tooltip" class="flex flex-row items-center">
{$_('toolbar.routing.use_routing_tooltip')}
<Shortcut key="F5" />
</span> </span>
</Tooltip> <Tooltip label={$_('toolbar.routing.use_routing_tooltip')}>
<Switch class="scale-90" bind:checked={$routing} />
<Shortcut slot="extra" key="F5" />
</Tooltip>
</Label>
{#if $routing} {#if $routing}
<div class="flex flex-col gap-3" in:slide> <div class="flex flex-col gap-3" in:slide>
<Label class="w-full flex flex-row justify-between items-center gap-2"> <Label class="flex flex-row justify-between items-center gap-2">
<span class="shrink-0 flex flex-row items-center gap-1"> <span class="shrink-0 flex flex-row items-center gap-1">
{#if $routingProfileSelectItem.value.includes('bike') || $routingProfileSelectItem.value.includes('motorcycle')} {#if $routingProfileSelectItem.value.includes('bike') || $routingProfileSelectItem.value.includes('motorcycle')}
<Bike size="16" /> <Bike size="16" />
@@ -162,81 +160,73 @@
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
</Label> </Label>
<Label class="w-full flex flex-row justify-between items-center gap-2"> <Label class="flex flex-row justify-between items-center gap-2">
<span class="flex flex-row gap-1" <span class="flex flex-row gap-1">
><TriangleAlert size="16" />{$_('toolbar.routing.allow_private')}</span <TriangleAlert size="16" />
> {$_('toolbar.routing.allow_private')}
</span>
<Switch class="scale-90" bind:checked={$privateRoads} /> <Switch class="scale-90" bind:checked={$privateRoads} />
</Label> </Label>
</div> </div>
{/if} {/if}
</div> </div>
<div class="flex flex-row flex-wrap justify-center gap-1"> <div class="flex flex-row flex-wrap justify-center gap-1">
<Tooltip> <ButtonWithTooltip
<Button label={$_('toolbar.routing.reverse.tooltip')}
slot="data" variant="outline"
variant="outline" class="flex flex-row gap-1 text-xs px-2"
class="flex flex-row gap-1 text-xs px-2" disabled={!validSelection}
disabled={!validSelection} on:click={dbUtils.reverseSelection}
on:click={dbUtils.reverseSelection} >
> <ArrowRightLeft size="12" />{$_('toolbar.routing.reverse.button')}
<ArrowRightLeft size="12" />{$_('toolbar.routing.reverse.button')} </ButtonWithTooltip>
</Button> <ButtonWithTooltip
<span slot="tooltip">{$_('toolbar.routing.reverse.tooltip')}</span> label={$_('toolbar.routing.route_back_to_start.tooltip')}
</Tooltip> variant="outline"
<Tooltip> class="flex flex-row gap-1 text-xs px-2"
<Button disabled={!validSelection}
slot="data" on:click={() => {
variant="outline" const selected = getOrderedSelection();
class="flex flex-row gap-1 text-xs px-2" if (selected.length > 0) {
disabled={!validSelection} const firstFileId = selected[0].getFileId();
on:click={() => { const firstFile = getFile(firstFileId);
const selected = getOrderedSelection(); if (firstFile) {
if (selected.length > 0) { let start = (() => {
const firstFileId = selected[0].getFileId(); if (selected[0] instanceof ListFileItem) {
const firstFile = getFile(firstFileId); return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
if (firstFile) { } else if (selected[0] instanceof ListTrackItem) {
let start = (() => { return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]?.trkpt[0];
if (selected[0] instanceof ListFileItem) { } else if (selected[0] instanceof ListTrackSegmentItem) {
return firstFile.trk[0]?.trkseg[0]?.trkpt[0]; return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[
} else if (selected[0] instanceof ListTrackItem) { selected[0].getSegmentIndex()
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]?.trkpt[0]; ]?.trkpt[0];
} else if (selected[0] instanceof ListTrackSegmentItem) {
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[
selected[0].getSegmentIndex()
]?.trkpt[0];
}
})();
if (start !== undefined) {
const lastFileId = selected[selected.length - 1].getFileId();
routingControls
.get(lastFileId)
?.appendAnchorWithCoordinates(start.getCoordinates());
} }
})();
if (start !== undefined) {
const lastFileId = selected[selected.length - 1].getFileId();
routingControls
.get(lastFileId)
?.appendAnchorWithCoordinates(start.getCoordinates());
} }
} }
}} }
> }}
<Home size="12" />{$_('toolbar.routing.route_back_to_start.button')} >
</Button> <Home size="12" />{$_('toolbar.routing.route_back_to_start.button')}
<span slot="tooltip">{$_('toolbar.routing.route_back_to_start.tooltip')}</span> </ButtonWithTooltip>
</Tooltip> <ButtonWithTooltip
<Tooltip> label={$_('toolbar.routing.round_trip.tooltip')}
<Button variant="outline"
slot="data" class="flex flex-row gap-1 text-xs px-2"
variant="outline" disabled={!validSelection}
class="flex flex-row gap-1 text-xs px-2" on:click={dbUtils.createRoundTripForSelection}
disabled={!validSelection} >
on:click={dbUtils.createRoundTripForSelection} <Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
> </ButtonWithTooltip>
<Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
</Button>
<span slot="tooltip">{$_('toolbar.routing.round_trip.tooltip')}</span>
</Tooltip>
</div> </div>
<div class="w-full flex flex-row gap-2 items-end justify-between"> <div class="w-full flex flex-row gap-2 items-end justify-between">
<Help link="./help/toolbar/routing"> <Help link={getURLForLanguage($locale, '/help/toolbar/routing')}>
{#if !validSelection} {#if !validSelection}
{$_('toolbar.routing.help_no_file')} {$_('toolbar.routing.help_no_file')}
{:else} {:else}

View File

@@ -24,7 +24,7 @@ export const routingProfileSelectItem = writable({
}); });
derived([routingProfile, locale, isLoading], ([profile, l, i]) => [profile, l, i]).subscribe(([profile, l, i]) => { derived([routingProfile, locale, isLoading], ([profile, l, i]) => [profile, l, i]).subscribe(([profile, l, i]) => {
if (!i && profile !== '' && profile !== get(routingProfileSelectItem).value && l !== null) { if (!i && profile !== '' && (profile !== get(routingProfileSelectItem).value || get(_)(`toolbar.routing.activities.${profile}`) !== get(routingProfileSelectItem).label) && l !== null) {
routingProfileSelectItem.update((item) => { routingProfileSelectItem.update((item) => {
item.value = profile; item.value = profile;
item.label = get(_)(`toolbar.routing.activities.${profile}`); item.label = get(_)(`toolbar.routing.activities.${profile}`);
@@ -66,7 +66,7 @@ async function getRoute(points: Coordinates[], brouterProfile: string, privateRo
const latIdx = messages[0].indexOf("Latitude"); const latIdx = messages[0].indexOf("Latitude");
const tagIdx = messages[0].indexOf("WayTags"); const tagIdx = messages[0].indexOf("WayTags");
let messageIdx = 1; let messageIdx = 1;
let surface = messageIdx < messages.length ? getSurface(messages[messageIdx][tagIdx]) : "unknown"; let surface = messageIdx < messages.length ? getSurface(messages[messageIdx][tagIdx]) : undefined;
for (let i = 0; i < coordinates.length; i++) { for (let i = 0; i < coordinates.length; i++) {
let coord = coordinates[i]; let coord = coordinates[i];
@@ -77,27 +77,30 @@ async function getRoute(points: Coordinates[], brouterProfile: string, privateRo
}, },
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0) ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0)
})); }));
route[route.length - 1].setSurface(surface)
if (messageIdx < messages.length && if (messageIdx < messages.length &&
coordinates[i][0] == Number(messages[messageIdx][lngIdx]) / 1000000 && coordinates[i][0] == Number(messages[messageIdx][lngIdx]) / 1000000 &&
coordinates[i][1] == Number(messages[messageIdx][latIdx]) / 1000000) { coordinates[i][1] == Number(messages[messageIdx][latIdx]) / 1000000) {
messageIdx++; messageIdx++;
if (messageIdx == messages.length) surface = "unknown"; if (messageIdx == messages.length) surface = undefined;
else surface = getSurface(messages[messageIdx][tagIdx]); else surface = getSurface(messages[messageIdx][tagIdx]);
} }
if (surface) {
route[route.length - 1].setSurface(surface);
}
} }
return route; return route;
} }
function getSurface(message: string): string { function getSurface(message: string): string | undefined {
const fields = message.split(" "); const fields = message.split(" ");
for (let i = 0; i < fields.length; i++) if (fields[i].startsWith("surface=")) { for (let i = 0; i < fields.length; i++) if (fields[i].startsWith("surface=")) {
return fields[i].substring(8); return fields[i].substring(8);
} }
return "unknown"; return undefined;
}; };
function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> { function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
@@ -125,13 +128,10 @@ function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
} }
})); }));
let m = get(map); return getElevation(route).then((elevations) => {
route.forEach((point) => { route.forEach((point, i) => {
point.setSurface("unknown"); point.ele = elevations[i];
if (m) { });
point.ele = getElevation(m, point.getCoordinates()); return route;
}
}); });
return new Promise((resolve) => resolve(route));
} }

View File

@@ -30,7 +30,7 @@
> >
<Trash2 size="16" class="mr-1" /> <Trash2 size="16" class="mr-1" />
{$_('menu.delete')} {$_('menu.delete')}
<Shortcut key="" shift={true} click={true} /> <Shortcut shift={true} click={true} />
</Button> </Button>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>

View File

@@ -1,4 +1,4 @@
import { distance, type Coordinates, TrackPoint, TrackSegment, Track, crossarcDistance } from "gpx"; import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from "gpx";
import { get, writable, type Readable } from "svelte/store"; import { get, writable, type Readable } from "svelte/store";
import mapboxgl from "mapbox-gl"; import mapboxgl from "mapbox-gl";
import { route } from "./Routing"; import { route } from "./Routing";
@@ -81,7 +81,6 @@ export class RoutingControls {
add() { add() {
this.active = true; this.active = true;
this.map.on('zoom', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.on('move', this.toggleAnchorsForZoomLevelAndBoundsBinded); this.map.on('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.on('click', this.appendAnchorBinded); this.map.on('click', this.appendAnchorBinded);
this.map.on('mousemove', this.fileId, this.showTemporaryAnchorBinded); this.map.on('mousemove', this.fileId, this.showTemporaryAnchorBinded);
@@ -129,7 +128,6 @@ export class RoutingControls {
for (let anchor of this.anchors) { for (let anchor of this.anchors) {
anchor.marker.remove(); anchor.marker.remove();
} }
this.map.off('zoom', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.off('move', this.toggleAnchorsForZoomLevelAndBoundsBinded); this.map.off('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.off('click', this.appendAnchorBinded); this.map.off('click', this.appendAnchorBinded);
this.map.off('mousemove', this.fileId, this.showTemporaryAnchorBinded); this.map.off('mousemove', this.fileId, this.showTemporaryAnchorBinded);
@@ -187,11 +185,13 @@ export class RoutingControls {
return (e: any) => { return (e: any) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (marker === this.temporaryAnchor.marker) {
if (Date.now() - this.lastDragEvent < 100) { // Prevent click event during drag
return; return;
} }
if (Date.now() - this.lastDragEvent < 100) { // Prevent click event during drag if (marker === this.temporaryAnchor.marker) {
this.turnIntoPermanentAnchor();
return; return;
} }
@@ -228,14 +228,15 @@ export class RoutingControls {
toggleAnchorsForZoomLevelAndBounds() { // Show markers only if they are in the current zoom level and bounds toggleAnchorsForZoomLevelAndBounds() { // Show markers only if they are in the current zoom level and bounds
this.shownAnchors.splice(0, this.shownAnchors.length); this.shownAnchors.splice(0, this.shownAnchors.length);
let southWest = this.map.unproject([0, this.map.getCanvas().height]); let center = this.map.getCenter();
let northEast = this.map.unproject([this.map.getCanvas().width, 0]); let bottomLeft = this.map.unproject([0, this.map.getCanvas().height]);
let bounds = new mapboxgl.LngLatBounds(southWest, northEast); let topRight = this.map.unproject([this.map.getCanvas().width, 0]);
let diagonal = bottomLeft.distanceTo(topRight);
let zoom = this.map.getZoom(); let zoom = this.map.getZoom();
this.anchors.forEach((anchor) => { this.anchors.forEach((anchor) => {
anchor.inZoom = anchor.point._data.zoom <= zoom; anchor.inZoom = anchor.point._data.zoom <= zoom;
if (anchor.inZoom && bounds.contains(anchor.marker.getLngLat())) { if (anchor.inZoom && center.distanceTo(anchor.marker.getLngLat()) < diagonal) {
anchor.marker.addTo(this.map); anchor.marker.addTo(this.map);
this.shownAnchors.push(anchor); this.shownAnchors.push(anchor);
} else { } else {
@@ -335,14 +336,14 @@ export class RoutingControls {
let file = get(this.file)?.file; let file = get(this.file)?.file;
// Find the point closest to the temporary anchor // Find the point closest to the temporary anchor
let minDistance = Number.MAX_VALUE; let minDetails: any = { distance: Number.MAX_VALUE };
let minAnchor = this.temporaryAnchor as Anchor; let minAnchor = this.temporaryAnchor as Anchor;
file?.forEachSegment((segment, trackIndex, segmentIndex) => { file?.forEachSegment((segment, trackIndex, segmentIndex) => {
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) { if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
let details: any = {}; let details: any = {};
let closest = getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details); let closest = getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
if (details.distance < minDistance) { if (details.distance < minDetails.distance) {
minDistance = details.distance; minDetails = details;
minAnchor = { minAnchor = {
point: closest, point: closest,
segment, segment,
@@ -353,9 +354,65 @@ export class RoutingControls {
} }
}); });
if (minAnchor.point._data.anchor) {
minAnchor.point = minAnchor.point.clone();
if (minDetails.before) {
minAnchor.point._data.index = minAnchor.point._data.index + 0.5;
} else {
minAnchor.point._data.index = minAnchor.point._data.index - 0.5;
}
}
return minAnchor; return minAnchor;
} }
turnIntoPermanentAnchor() {
let file = get(this.file)?.file;
// Find the point closest to the temporary anchor
let minDetails: any = { distance: Number.MAX_VALUE };
let minInfo = {
point: this.temporaryAnchor.point,
trackIndex: -1,
segmentIndex: -1,
trkptIndex: -1
};
file?.forEachSegment((segment, trackIndex, segmentIndex) => {
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
let details: any = {};
getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
if (details.distance < minDetails.distance) {
minDetails = details;
let before = details.before ? details.index : details.index - 1;
let projectedPt = projectedPoint(segment.trkpt[before], segment.trkpt[before + 1], this.temporaryAnchor.point);
let ratio = distance(segment.trkpt[before], projectedPt) / distance(segment.trkpt[before], segment.trkpt[before + 1]);
let point = segment.trkpt[before].clone();
point.setCoordinates(projectedPt);
point.ele = (1 - ratio) * (segment.trkpt[before].ele ?? 0) + ratio * (segment.trkpt[before + 1].ele ?? 0);
point.time = (segment.trkpt[before].time && segment.trkpt[before + 1].time) ? new Date((1 - ratio) * segment.trkpt[before].time.getTime() + ratio * segment.trkpt[before + 1].time.getTime()) : undefined;
point._data = {
anchor: true,
zoom: 0
};
minInfo = {
point,
trackIndex,
segmentIndex,
trkptIndex: before + 1
};
}
}
});
if (minInfo.trackIndex !== -1) {
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(minInfo.trackIndex, minInfo.segmentIndex, minInfo.trkptIndex, minInfo.trkptIndex - 1, [minInfo.point]));
}
}
getDeleteAnchor(anchor: Anchor) { getDeleteAnchor(anchor: Anchor) {
return () => this.deleteAnchor(anchor); return () => this.deleteAnchor(anchor);
} }

View File

@@ -17,11 +17,12 @@
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { gpxStatistics, map, slicedGPXStatistics, splitAs } from '$lib/stores'; import { gpxStatistics, map, slicedGPXStatistics, splitAs } from '$lib/stores';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { _ } from 'svelte-i18n'; import { _, locale } from 'svelte-i18n';
import { onDestroy, tick } from 'svelte'; import { onDestroy, tick } from 'svelte';
import { Crop } from 'lucide-svelte'; import { Crop } from 'lucide-svelte';
import { dbUtils } from '$lib/db'; import { dbUtils } from '$lib/db';
import { SplitControls } from './SplitControls'; import { SplitControls } from './SplitControls';
import { getURLForLanguage } from '$lib/utils';
let splitControls: SplitControls | undefined = undefined; let splitControls: SplitControls | undefined = undefined;
let canCrop = false; let canCrop = false;
@@ -37,8 +38,8 @@
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
$gpxStatistics.local.points.length > 0; $gpxStatistics.local.points.length > 0;
let maxSliderValue = 100; let maxSliderValue = 1;
let sliderValues = [0, 100]; let sliderValues = [0, 1];
function updateCanCrop() { function updateCanCrop() {
canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue; canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue;
@@ -66,7 +67,7 @@
if (validSelection && $gpxStatistics.local.points.length > 0) { if (validSelection && $gpxStatistics.local.points.length > 0) {
maxSliderValue = $gpxStatistics.local.points.length - 1; maxSliderValue = $gpxStatistics.local.points.length - 1;
} else { } else {
maxSliderValue = 100; maxSliderValue = 1;
} }
await tick(); await tick();
sliderValues = [0, maxSliderValue]; sliderValues = [0, maxSliderValue];
@@ -135,7 +136,7 @@
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
</Label> </Label>
<Help link="./help/toolbar/scissors"> <Help link={getURLForLanguage($locale, '/help/toolbar/scissors')}>
{#if validSelection} {#if validSelection}
{$_('toolbar.scissors.help')} {$_('toolbar.scissors.help')}
{:else} {:else}

View File

@@ -49,9 +49,7 @@ export class SplitControls {
} }
updateControls() { // Update the markers when the files change updateControls() { // Update the markers when the files change
let controlIndex = 0; let controlIndex = 0;
applyToOrderedSelectedItemsFromFile((fileId, level, items) => { applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = getFile(fileId); let file = getFile(fileId);
@@ -61,6 +59,7 @@ export class SplitControls {
for (let point of segment.trkpt.slice(1, -1)) { // Update the existing controls (could be improved by matching the existing controls with the new ones?) for (let point of segment.trkpt.slice(1, -1)) { // Update the existing controls (could be improved by matching the existing controls with the new ones?)
if (point._data.anchor) { if (point._data.anchor) {
if (controlIndex < this.controls.length) { if (controlIndex < this.controls.length) {
this.controls[controlIndex].fileId = fileId;
this.controls[controlIndex].point = point; this.controls[controlIndex].point = point;
this.controls[controlIndex].segment = segment; this.controls[controlIndex].segment = segment;
this.controls[controlIndex].trackIndex = trackIndex; this.controls[controlIndex].trackIndex = trackIndex;
@@ -117,7 +116,7 @@ export class SplitControls {
createControl(point: TrackPoint, segment: TrackSegment, fileId: string, trackIndex: number, segmentIndex: number): ControlWithMarker { createControl(point: TrackPoint, segment: TrackSegment, fileId: string, trackIndex: number, segmentIndex: number): ControlWithMarker {
let element = document.createElement('div'); 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.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"', ""); element.innerHTML = Scissors.replace('width="24"', "").replace('height="24"', "").replace('stroke="currentColor"', 'stroke="black"');
let marker = new mapboxgl.Marker({ let marker = new mapboxgl.Marker({
draggable: true, draggable: true,
@@ -137,7 +136,7 @@ export class SplitControls {
marker.getElement().addEventListener('click', (e) => { marker.getElement().addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
dbUtils.split(fileId, trackIndex, segmentIndex, point.getCoordinates(), point._data.index); dbUtils.split(control.fileId, control.trackIndex, control.segmentIndex, control.point.getCoordinates(), control.point._data.index);
}); });
return control; return control;

View File

@@ -10,7 +10,7 @@ import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simpli
import { SplitType } from '$lib/components/toolbar/tools/scissors/Scissors.svelte'; import { SplitType } from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import { getClosestLinePoint, getElevation } from '$lib/utils'; import { getClosestLinePoint, getElevation } from '$lib/utils';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import type mapboxgl from 'mapbox-gl';
enableMapSet(); enableMapSet();
enablePatches(); enablePatches();
@@ -80,7 +80,7 @@ export function dexieSettingStore<T>(key: string, initial: T, initialize: boolea
} }
export const settings = { export const settings = {
distanceUnits: dexieSettingStore<'metric' | 'imperial'>('distanceUnits', 'metric'), distanceUnits: dexieSettingStore<'metric' | 'imperial' | 'nautical'>('distanceUnits', 'metric'),
velocityUnits: dexieSettingStore<'speed' | 'pace'>('velocityUnits', 'speed'), velocityUnits: dexieSettingStore<'speed' | 'pace'>('velocityUnits', 'speed'),
temperatureUnits: dexieSettingStore<'celsius' | 'fahrenheit'>('temperatureUnits', 'celsius'), temperatureUnits: dexieSettingStore<'celsius' | 'fahrenheit'>('temperatureUnits', 'celsius'),
elevationProfile: dexieSettingStore('elevationProfile', true), elevationProfile: dexieSettingStore('elevationProfile', true),
@@ -111,7 +111,6 @@ export const settings = {
defaultWeight: dexieSettingStore('defaultWeight', (browser && window.innerWidth < 600) ? 8 : 5), defaultWeight: dexieSettingStore('defaultWeight', (browser && window.innerWidth < 600) ? 8 : 5),
bottomPanelSize: dexieSettingStore('bottomPanelSize', 170), bottomPanelSize: dexieSettingStore('bottomPanelSize', 170),
rightPanelSize: dexieSettingStore('rightPanelSize', 240), rightPanelSize: dexieSettingStore('rightPanelSize', 240),
showWelcomeMessage: dexieSettingStore('showWelcomeMessage', true, false),
}; };
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber // Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber
@@ -181,7 +180,7 @@ function dexieGPXFileStore(id: string): Readable<GPXFileWithStatistics> & { dest
let statistics = new GPXStatisticsTree(gpx); let statistics = new GPXStatisticsTree(gpx);
if (!fileState.has(id)) { // Update the map bounds for new files if (!fileState.has(id)) { // Update the map bounds for new files
updateTargetMapBounds(statistics.getStatisticsFor(new ListFileItem(id)).global.bounds); updateTargetMapBounds(id, statistics.getStatisticsFor(new ListFileItem(id)).global.bounds);
} }
fileState.set(id, gpx); fileState.set(id, gpx);
@@ -288,12 +287,12 @@ export const fileObservers: Writable<Map<string, Readable<GPXFileWithStatistics
const fileState: Map<string, GPXFile> = new Map(); // Used to generate patches const fileState: Map<string, GPXFile> = new Map(); // Used to generate patches
// Observe the file ids in the database, and maintain a map of file observers for the corresponding files // Observe the file ids in the database, and maintain a map of file observers for the corresponding files
export function observeFilesFromDatabase() { export function observeFilesFromDatabase(fitBounds: boolean) {
let initialize = true; let initialize = true;
liveQuery(() => db.fileids.toArray()).subscribe(dbFileIds => { liveQuery(() => db.fileids.toArray()).subscribe(dbFileIds => {
if (initialize) { if (initialize) {
if (dbFileIds.length > 0) { if (fitBounds && dbFileIds.length > 0) {
initTargetMapBounds(dbFileIds.length); initTargetMapBounds(dbFileIds);
} }
initialize = false; initialize = false;
} }
@@ -454,13 +453,14 @@ export const dbUtils = {
}); });
}, },
addMultiple: (files: GPXFile[]) => { addMultiple: (files: GPXFile[]) => {
return applyGlobal((draft) => { let ids = getFileIds(files.length);
let ids = getFileIds(files.length); applyGlobal((draft) => {
files.forEach((file, index) => { files.forEach((file, index) => {
file._data.id = ids[index]; file._data.id = ids[index];
draft.set(file._data.id, freeze(file)); draft.set(file._data.id, freeze(file));
}); });
}); });
return ids;
}, },
applyToFile: (id: string, callback: (file: WritableDraft<GPXFile>) => void) => { applyToFile: (id: string, callback: (file: WritableDraft<GPXFile>) => void) => {
applyToFiles([id], callback); applyToFiles([id], callback);
@@ -513,8 +513,17 @@ export const dbUtils = {
}); });
}); });
}, },
addNewTrack: (fileId: string) => {
dbUtils.applyToFile(fileId, (file) => file.replaceTracks(file.trk.length, file.trk.length, [new Track()]));
},
addNewSegment: (fileId: string, trackIndex: number) => {
dbUtils.applyToFile(fileId, (file) => {
let track = file.trk[trackIndex];
track.replaceTrackSegments(track.trkseg.length, track.trkseg.length, [new TrackSegment()]);
});
},
reverseSelection: () => { reverseSelection: () => {
if (!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) { if (!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) || get(gpxStatistics).local.points?.length <= 1) {
return; return;
} }
applyGlobal((draft) => { applyGlobal((draft) => {
@@ -912,29 +921,30 @@ export const dbUtils = {
if (m === null) { if (m === null) {
return; return;
} }
let ele = getElevation(m, waypoint.attributes); getElevation([waypoint.attributes]).then((elevation) => {
if (item) { if (item) {
dbUtils.applyToFile(item.getFileId(), (file) => { dbUtils.applyToFile(item.getFileId(), (file) => {
let wpt = file.wpt[item.getWaypointIndex()]; let wpt = file.wpt[item.getWaypointIndex()];
wpt.name = waypoint.name; wpt.name = waypoint.name;
wpt.desc = waypoint.desc; wpt.desc = waypoint.desc;
wpt.cmt = waypoint.cmt; wpt.cmt = waypoint.cmt;
wpt.sym = waypoint.sym; wpt.sym = waypoint.sym;
wpt.link = waypoint.link; wpt.link = waypoint.link;
wpt.setCoordinates(waypoint.attributes); wpt.setCoordinates(waypoint.attributes);
wpt.ele = ele; wpt.ele = elevation[0];
}); });
} else { } else {
let fileIds = new Set<string>(); let fileIds = new Set<string>();
get(selection).getSelected().forEach((item) => { get(selection).getSelected().forEach((item) => {
fileIds.add(item.getFileId()); fileIds.add(item.getFileId());
}); });
let wpt = new Waypoint(waypoint); let wpt = new Waypoint(waypoint);
wpt.ele = ele; wpt.ele = elevation[0];
dbUtils.applyToFiles(Array.from(fileIds), (file) => dbUtils.applyToFiles(Array.from(fileIds), (file) =>
file.replaceWaypoints(file.wpt.length, file.wpt.length, [wpt]) file.replaceWaypoints(file.wpt.length, file.wpt.length, [wpt])
); );
} }
});
}, },
setStyleToSelection: (style: LineStyleExtension) => { setStyleToSelection: (style: LineStyleExtension) => {
if (get(selection).size === 0) { if (get(selection).size === 0) {
@@ -1022,6 +1032,66 @@ export const dbUtils = {
}); });
}); });
}, },
addElevationToSelection: async (map: mapboxgl.Map) => {
if (get(selection).size === 0) {
return;
}
let points: (TrackPoint | Waypoint)[] = [];
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = fileState.get(fileId);
if (file) {
if (level === ListLevel.FILE) {
points.push(...file.getTrackPoints());
points.push(...file.wpt);
} else if (level === ListLevel.TRACK) {
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
trackIndices.forEach((trackIndex) => {
points.push(...file.trk[trackIndex].getTrackPoints());
});
} else if (level === ListLevel.SEGMENT) {
let trackIndex = (items[0] as ListTrackSegmentItem).getTrackIndex();
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
segmentIndices.forEach((segmentIndex) => {
points.push(...file.trk[trackIndex].trkseg[segmentIndex].getTrackPoints());
});
} else if (level === ListLevel.WAYPOINTS) {
points.push(...file.wpt);
} else if (level === ListLevel.WAYPOINT) {
let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex());
points.push(...waypointIndices.map((waypointIndex) => file.wpt[waypointIndex]));
}
}
});
if (points.length === 0) {
return;
}
getElevation(points).then((elevations) => {
applyGlobal((draft) => {
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = draft.get(fileId);
if (file) {
if (level === ListLevel.FILE) {
file.addElevation(elevations);
} else if (level === ListLevel.TRACK) {
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
file.addElevation(elevations, trackIndices, undefined, []);
} else if (level === ListLevel.SEGMENT) {
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
file.addElevation(elevations, trackIndices, segmentIndices, []);
} else if (level === ListLevel.WAYPOINTS) {
file.addElevation(elevations, [], [], undefined);
} else if (level === ListLevel.WAYPOINT) {
let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex());
file.addElevation(elevations, [], [], waypointIndices);
}
}
});
});
});
},
deleteSelectedFiles: () => { deleteSelectedFiles: () => {
if (get(selection).size === 0) { if (get(selection).size === 0) {
return; return;

View File

@@ -0,0 +1,35 @@
---
title: FAQ
---
<script>
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
### Do I need to donate to use the website?
No.
The website is free to use and always will be (as long as it is financially sustainable).
However, donations are appreciated and help keep the website running.
### Why is this route chosen over that one? _Or_ how can I add something to the map?
**gpx.studio** uses data from <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a>, which is an open and collaborative world map.
This means you can contribute to the map by adding or editing data on OpenStreetMap.
If you have never contributed to OpenStreetMap before, here is how you can suggest changes:
1. Go to the location where you want to add or edit data on the <a href="https://www.openstreetmap.org/" target="_blank">map</a>.
2. Use the <button>Query features</button> tool on the right to inspect the existing data.
3. Right-click on the location and select <button>Add a note here</button>.
4. Explain what is incorrect or missing in the note and click <button>Add note</button> to submit it.
Someone more experienced with OpenStreetMap will then review your note and make the necessary changes.
<DocsNote>
More information on how to contribute to OpenStreetMap can be found <a href="https://wiki.openstreetmap.org/wiki/How_to_contribute" target="_blank">here</a>.
</DocsNote>

View File

@@ -0,0 +1,82 @@
---
title: Files and statistics
---
<script>
import { TriangleRight, BrickWall, Zap, HeartPulse, Orbit, Thermometer, SquareActivity } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
## File list
Once you have [opened](./menu/file) files, they will be shown as tabs in the file list located at the bottom of the map.
You can reorder them by dragging and dropping the tabs.
And when many files are open, you can scroll through the list of tabs to navigate between them.
<DocsNote>
When using a mouse, you need to hold <kbd>Shift</kbd> to scroll horizontally.
</DocsNote>
### File selection
By clicking on a tab, you can switch between the files to inspect their statistics, and apply [edit actions](./menu/edit) and [tools](./toolbar) to them.
By holding the <kbd>Ctrl/Cmd</kbd> key, you can add files to the selection or remove them, and by holding <kbd>Shift</kbd>, you can select a range of files.
Most of the [edit actions](./menu/edit) and [tools](./toolbar) can be applied to multiple files at once.
<DocsNote>
You can also navigate through the files using the arrow keys on your keyboard, and use <kbd>Shift</kbd> to add files to the selection.
</DocsNote>
### Edit actions
By right-clicking on a file tab, you can access the same actions as in the [edit menu](./menu/edit).
### Vertical layout
As mentioned in the [view options section](./menu/view), you can switch between a horizontal and a vertical layout for the file list.
The vertical file list is useful when you have many files open, or files with multiple [tracks, segments, or points of interest](./gpx).
Indeed, this layout allows you to inspect the content of the files through collapsible sections.
You can also apply [edit actions](./menu/edit) and [tools](./toolbar) to internal file items.
Furthermore, you can drag and drop the inner items to reorder them, or move them in the hierarchy or even to another file.
<DocsNote>
The size of the file list can be adjusted by dragging the separator between the map and the file list.
</DocsNote>
## Elevation profile and statistics
At the bottom of the interface, you can find the elevation profile and statistics for the current selection.
<DocsNote>
The size of the elevation profile can be adjusted by dragging the separator between the map and the elevation profile.
</DocsNote>
### Interactive statistics
When hovering over the elevation profile, a tooltip will show statistics at the cursor position.
To get the statistics for a specific section of the elevation profile, you can drag a selection rectangle on the profile.
Click on the profile to reset the selection.
You can also use the mouse wheel to zoom in and out on the elevation profile, and move left and right by dragging the profile while holding the <kbd>Shift</kbd> key.
### Additional data
Using the buttons on the right of the elevation profile, you can optionally color the elevation profile by:
- **slope** <TriangleRight size="16" class="inline-block" style="margin-bottom: 2px" /> information computed from the elevation data, or
- **surface** <BrickWall size="16" class="inline-block" style="margin-bottom: 2px" /> data coming from <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a>'s <a href="https://wiki.openstreetmap.org/wiki/Key:surface" target="_blank">surface</a> tags.
This is only available for files created with **gpx.studio**.
If your selection includes it, you can also visualize: **speed** <Zap size="16" class="inline-block" style="margin-bottom: 2px" />, **heart rate** <HeartPulse size="16" class="inline-block" style="margin-bottom: 2px" />, **cadence** <Orbit size="16" class="inline-block" style="margin-bottom: 2px" />, **temperature** <Thermometer size="16" class="inline-block" style="margin-bottom: 2px" />, and **power** <SquareActivity size="16" class="inline-block" style="margin-bottom: 2px" /> data on the elevation profile.

View File

@@ -0,0 +1,37 @@
---
title: Getting started
---
<script lang="ts">
import DocsImage from '$lib/components/docs/DocsImage.svelte';
</script>
# { title }
Welcome to the official guide for **gpx.studio**!
This guide will walk you through all the components and tools of the interface, helping you become a proficient user of the application.
<DocsImage src="getting-started/interface" alt="The gpx.studio interface." />
As shown in the screenshot above, the interface is divided into four main sections organized around the map.
Before we dive into the details of each section, let's have a quick overview of the interface.
## Menu
At the top of the interface, you will find the [main menu](./menu).
This is where you can access common actions such as opening, closing, and exporting files, undoing and redoing actions, and adjusting the application settings.
## Files and statistics
At the bottom of the interface, you will find the list of files currently open in the application.
You can click on a file to select it and display its statistics below the list.
In the [dedicated section](./files-and-stats), we will explain how to select multiple files and switch to a vertical layout for advanced file management.
## Toolbar
On the left side of the interface, you will find the [toolbar](./toolbar), which contains all the tools you can use to edit your files.
## Map controls
Finally, on the right side of the interface, you will find the [map controls](./map-controls).
These controls allow you to navigate the map, zoom in and out, and switch between different map styles.

View File

@@ -0,0 +1,34 @@
---
title: GPX file format
---
<script>
import { Waypoints, MapPin } from 'lucide-svelte';
</script>
# { title }
The <a href="https://www.topografix.com/gpx.asp" target="_blank">GPX file format</a> is an open standard for exchanging GPS data between applications and GPS devices.
It essentially consists of a series of GPS points encoding one or multiple GPS traces, and, optionally, some points of interest.
GPX files may also contain metadata, of which the **name** and **description** fields are the most useful for users.
### <Waypoints size="16" class="inline-block" style="margin-bottom: 2px" /> Tracks, segments, and GPS points
As mentioned above, a GPX file can contain multiple GPS traces.
These are organized in a hierarchical structure, with tracks at the top level.
- A **track** is made of a sequence of disconnected segments.
Furthermore, it can contain metadata such as a **name**, a **description**, and **appearance properties**.
- A **segment** is a sequence of GPS points that form a continuous path.
- A **GPS point** is a location with a latitude, a longitude, and optionally a timestamp and an altitude.
Some devices also store additional information such as heart rate, cadence, temperature, and power.
In most cases, GPX files contain a single track with a single segment.
However, the hierarchy described above allows for more advanced use cases, such as planning multi-day trips with several variants for each day.
### <MapPin size="16" class="inline-block" style="margin-bottom: 2px" /> Points of interest
**Points of interest** (technically called _waypoints_) represent locations of interest to show either on a GPS device or on a digital map.
In addition to its coordinates, a point of interest can have a **name** and a **description**.

View File

@@ -0,0 +1,13 @@
<script>
import { HeartHandshake } from 'lucide-svelte';
</script>
## <HeartHandshake size="18" class="mr-1 inline-block align-baseline" /> Help keep the website free (and ad-free)
Each time you add or move GPS points, our servers calculate the best route on the road network.
We also use APIs from <a href="https://mapbox.com" target="_blank">Mapbox</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
Unfortunately, this is expensive.
If you enjoy using this tool and find it valuable, please consider making a small donation to help keep the website free and ad-free.
Thank you very much for your support! ❤️

View File

@@ -0,0 +1,5 @@
Mapbox is the company that provides some of the beautiful maps on this website.
They also develop the <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">map engine</a> which powers **gpx.studio**.
We are incredibly fortunate and grateful to be part of their <a href="https://mapbox.com/community" target="_blank">Community</a> program, which supports nonprofits, educational institutions, and positive impact organizations.
This partnership allows **gpx.studio** to benefit from Mapbox tools at discounted prices, greatly contributing to the financial viability of the project and enabling us to offer the best possible user experience.

View File

@@ -0,0 +1,12 @@
<script>
import { Languages } from 'lucide-svelte';
</script>
## <Languages size="18" class="mr-1 inline-block align-baseline" /> Translation
The website is translated by volunteers using a collaborative translation platform.
You can contribute by adding or improving translations on our <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin project</a>.
If you would like to start translating into a new language, please <a href="#contact">get in touch</a>.
Any help is greatly appreciated!

View File

@@ -0,0 +1,27 @@
---
title: Integration
---
<script>
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import EmbeddingPlayground from '$lib/components/embedding/EmbeddingPlayground.svelte';
</script>
# { title }
You can use **gpx.studio** to create maps showing your GPX files and embed them in your website.
All you need is:
1. A <a href="https://account.mapbox.com/auth/signup" target="_blank">Mapbox access token</a> to load the map, and
2. GPX files hosted on your server or on Google Drive, or accessible via a public URL.
You can then play with the configurator below to customize your map and generate the corresponding HTML code.
<DocsNote type="warning">
You will need to set up <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS" target="_blank">Cross-Origin Resource Sharing (CORS)</a> headers on your server to allow <b>gpx.studio</b> to load your GPX files.
</DocsNote>
<EmbeddingPlayground />

View File

@@ -0,0 +1,70 @@
---
title: Map controls
---
<script>
import { Plus, Minus, Diff, Compass, Search, LocateFixed, PersonStanding, Layers } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import DocsLayers from '$lib/components/docs/DocsLayers.svelte';
</script>
# { title }
The map controls are located on the right side of the interface.
These controls allow you to navigate the map, zoom in and out, and switch between different map styles.
### <Diff size="16" class="inline-block" style="margin-bottom: 2px" /> Map navigation
The controls at the top allow you to zoom in <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> and out <Minus size="16" class="inline-block" style="margin-bottom: 2px" />, and to change the orientation and tilt of the map <Compass size="16" class="inline-block" style="margin-bottom: 2px" />.
<DocsNote>
To control the orientation and tilt of the map, you can also drag the map while holding <kbd>Ctrl</kbd>.
</DocsNote>
### <Search size="16" class="inline-block" style="margin-bottom: 2px" /> Search bar
You can use the search bar to look for an address and navigate to it on the map.
### <LocateFixed size="16" class="inline-block" style="margin-bottom: 2px" /> Locate button
The locate button centers the map on your current location.
<DocsNote>
This only works if you have allowed your browser and <b>gpx.studio</b> to access your location.
</DocsNote>
### <PersonStanding size="16" class="inline-block" style="margin-bottom: 2px" /> Street view
This button can be used to enable street view mode on the map.
Depending on the street view source chosen in the [settings](./menu/settings), street view imagery can be accessed differently.
- <a href="https://www.mapillary.com/" target="_blank">Mapillary</a>: the street view coverage will appear as green lines on the map. When zoomed in enough, green dots will show the exact locations where street view imagery is available. Hovering over a green dot will show the street view image at that location.
- <a href="https://www.google.com/streetview/" target="_blank">Google Street View</a>: click on the map to open a new tab with the street view imagery at that location.
### <Layers size="16" class="inline-block" style="margin-bottom: 2px" /> Map layers
The map layers button allows you to switch between different basemaps, and toggle map overlays and categories of points of interest.
- **Basemaps** are background maps that present the main geographic features of the world.
Depending on their purpose, basemaps have different styles and levels of detail.
Only one basemap can be displayed at a time.
- **Overlays** are additional layers that can be displayed on top of the basemap to provide complementary information.
- **Points of interest** can be added to the map to show different categories of places, such as shops, restaurants, or accommodations.
<div class="flex flex-col items-center">
<DocsLayers />
<span class="text-sm text-center mt-2">
Hover over the map to show the <a href="https://hiking.waymarkedtrails.org" target="_blank">Waymarked Trails hiking</a> overlay on top of the <a href="https://www.mapbox.com/maps/outdoors" target="_blank">Mapbox Outdoors</a> basemap.
</span>
</div>
A large collection of global and local basemaps and overlays is available in **gpx.studio**, as well as a selection of point-of-interest categories.
They can be enabled in the [map layer settings dialog](./menu/settings).
In these settings, you can also manage the opacity of the overlays.
For advanced users, it is possible to add custom basemaps and overlays by providing <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a>, or <a href="https://docs.mapbox.com/help/glossary/style/" target="_blank">Mapbox style JSON</a> URLs.

View File

@@ -0,0 +1,17 @@
---
title: Menu
---
<script lang="ts">
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
Асноўнае меню, размешчанае ў верхняй частцы інтэрфэйсу, забяспечвае доступ да дзеянняў, опцый і налад, падзеленых на некалькі катэгорый, якія тлумачацца асобна ў наступных раздзелах.
<DocsNote>
Большасць з дзеянняў таксама можа быць выклікана з дапамогай спалучэння клавіш адлюстраваных у меню.
</DocsNote>

View File

@@ -0,0 +1,96 @@
---
title: Edit actions
---
<script lang="ts">
import { Undo2, Redo2, Info, PaintBucket, EyeOff, FileStack, ClipboardCopy, Scissors, ClipboardPaste, Trash2, Maximize, Plus } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
Unlike the file actions, the edit actions can potentially modify the content of the currently selected files.
Moreover, when the vertical layout of the files list is enabled (see [Files and statistics](../files-and-stats)), they can also be applied to [tracks, segments, and points of interest](../gpx).
Therefore, we will refer to the elements that can be modified by these actions as _file items_.
Note that except for the undo and redo actions, the edit actions are also accessible through the context menu (right-click) of the file items.
### <Undo2 size="16" class="inline-block" style="margin-bottom: 2px" /><Redo2 size="16" class="inline-block" style="margin-bottom: 2px" /> Undo and redo
Using these buttons, you can undo or redo the last actions you performed.
This applies to all actions of the interface but not to view options, application settings, or map navigation.
### <Info size="16" class="inline-block" style="margin-bottom: 2px" /> Info...
Open the information dialog of the currently selected file item, where you can see and edit its name and description.
### <PaintBucket size="16" class="inline-block" style="margin-bottom: 2px" /> Appearance...
Open the appearance dialog, where you can change the color, opacity, and width of the selected file items on the map.
### <EyeOff size="16" class="inline-block" style="margin-bottom: 2px" /> Hide/unhide
Toggle the visibility of the selected file items on the map.
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New track
Create a new track in the selected file.
<DocsNote>
This action is only available when the vertical layout of the files list is enabled.
Additionally, the selection must be a single file.
</DocsNote>
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New segment
Create a new segment in the selected track.
<DocsNote>
This action is only available when the vertical layout of the files list is enabled.
Additionally, the selection must be a single track.
</DocsNote>
### <FileStack size="16" class="inline-block" style="margin-bottom: 2px" /> Select all
Add all file items in the current hierarchy level to the selection.
### <Maximize size="16" class="inline-block" style="margin-bottom: 2px" /> Center
Center the map on the selected file items.
### <ClipboardCopy size="16" class="inline-block" style="margin-bottom: 2px" /> Copy
Copy the selected file items to the clipboard.
<DocsNote>
This action is only available when the vertical layout of the files list is enabled.
</DocsNote>
### <Scissors size="16" class="inline-block" style="margin-bottom: 2px" /> Cut
Cut the selected file items to the clipboard.
<DocsNote>
This action is only available when the vertical layout of the files list is enabled.
</DocsNote>
### <ClipboardPaste size="16" class="inline-block" style="margin-bottom: 2px" /> Paste
Paste the file items from the clipboard to the current hierarchy level if they are compatible with it.
<DocsNote>
This action is only available when the vertical layout of the files list is enabled.
</DocsNote>
### <Trash2 size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
Delete the selected file items.

View File

@@ -0,0 +1,52 @@
---
title: File actions
---
<script lang="ts">
import { Plus, FolderOpen, Copy, FileX, Download } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
The file actions menu contains a set of pretty self-explanatory file operations.
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New
Create a new empty file.
### <FolderOpen size="16" class="inline-block" style="margin-bottom: 2px" /> Open...
Open files from your computer.
<DocsNote>
You can also drag and drop files directly from your file system into the window.
</DocsNote>
### <Copy size="16" class="inline-block" style="margin-bottom: 2px" /> Duplicate
Create a copy of the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Close
Close the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Close all
Close all files.
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Export...
Open the export dialog to save the currently selected files to your computer.
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Export all...
Open the export dialog to save all files to your computer.
<DocsNote type="warning">
If your download does not start after clicking the download button, please check your browser settings to allow downloads from <b>gpx.studio</b>.
</DocsNote>

View File

@@ -0,0 +1,50 @@
---
title: Settings
---
<script lang="ts">
import { Ruler, Zap, Thermometer, Languages, Sun, PersonStanding, Layers } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
### <Ruler size="16" class="inline-block" style="margin-bottom: 2px" /> Distance units
Change the units used to display distances in the interface.
### <Zap size="16" class="inline-block" style="margin-bottom: 2px" /> Velocity units
Change the units used to display velocities in the interface.
You can choose between distance per hour or minutes per distance, which can be more suitable for running activities.
### <Thermometer size="16" class="inline-block" style="margin-bottom: 2px" /> Temperature units
Change the units used to display temperatures in the interface.
### <Languages size="16" class="inline-block" style="margin-bottom: 2px" /> Language
Change the language used in the interface.
<DocsNote>
You can contribute by adding or improving translations on our <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin project</a>.
If you would like to start translating into a new language, please <a href="#contact">get in touch</a>.
Any help is greatly appreciated!
</DocsNote>
### <Sun size="16" class="inline-block" style="margin-bottom: 2px" /> Theme
Change the theme used in the interface.
### <PersonStanding size="16" class="inline-block" style="margin-bottom: 2px" /> Street view source
Change the source used for the [street view control](../map-controls).
The default one is <a href="https://www.mapillary.com" target="_blank">Mapillary</a>, but you can also use <a href="https://www.google.com/streetview/" target="_blank">Google Street View</a>.
Learn more about how to use the street view control in the [map controls section](../map-controls).
### <Layers size="16" class="inline-block" style="margin-bottom: 2px" /> Map layers...
Open a dialog where you can enable or disable map layers, add custom ones, change the opacity of overlays, and more.
More information about map layers can be found in the [map controls section](../map-controls).

View File

@@ -0,0 +1,48 @@
---
title: View options
---
<script lang="ts">
import { ChartArea, GalleryVertical, Map, Layers2, Coins, Milestone, Box } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
This menu provides options to rearrange the interface and the map view.
### <ChartArea size="16" class="inline-block" style="margin-bottom: 2px" /> Elevation profile
Hide the elevation profile to make room for the map, or show it to inspect the current selection.
### <GalleryVertical size="16" class="inline-block" style="margin-bottom: 2px" /> Vertical file list
Switch between a vertical and a horizontal layout for the file list.
The [vertical file list](../files-and-stats) is useful when you have many files open, or files with multiple [tracks, segments, or points of interest](../gpx).
### <Map size="16" class="inline-block" style="margin-bottom: 2px" /> Switch to previous basemap
Change the basemap to the one previously selected through the [map layer control](../map-controls).
### <Layers2 size="16" class="inline-block" style="margin-bottom: 2px" /> Toggle overlays
Toggle the visibility of the map overlays selected through the [map layer control](../map-controls).
### <Coins size="16" class="inline-block" style="margin-bottom: 2px" /> Distance markers
Toggle the visibility of distance markers on the map.
They are displayed for the current selection, like the [elevation profile](../files-and-stats).
### <Milestone size="16" class="inline-block" style="margin-bottom: 2px" /> Direction arrows
Toggle the visibility of direction arrows on the map.
### <Box size="16" class="inline-block" style="margin-bottom: 2px" /> Toggle 3D
Enter or exit the 3D map view.
<DocsNote>
To control the orientation and tilt of the map, you can also drag the map while holding <kbd>Ctrl</kbd>.
</DocsNote>

View File

@@ -0,0 +1,32 @@
---
title: Toolbar
---
<script lang="ts">
import Toolbar from '$lib/components/toolbar/Toolbar.svelte';
import { currentTool, Tool } from '$lib/stores';
import { onMount, onDestroy } from 'svelte';
onMount(() => {
currentTool.set(Tool.ROUTING);
});
onDestroy(() => {
currentTool.set(null);
});
</script>
# { title }
Панэль інструментаў знаходзіцца з левага боку мапы і з'яўляецца сэрцам прыкладання, яна забяспечвае доступ да асноўных функцый **gpx.studio**.
Кожны інструмент прадстаўлены цэтлікам і можа быць актываваны пстрыкам на яго.
<div class="flex flex-row justify-center text-foreground">
<div>
<Toolbar class="border rounded-md shadow-lg" />
</div>
</div>
З дапамогай [edit actions](./menu/edit), большасць інструментаў можа быць прыменена да некалькіх файлаў адначасова, а таксама для [inner tracks and segments](./gpx).
Наступныя секцыі апісваюць кожны інструмент больш дэтальна.

View File

@@ -0,0 +1,18 @@
---
title: Clean
---
<script>
import { SquareDashedMousePointer } from 'lucide-svelte';
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
</script>
# <SquareDashedMousePointer size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
When the clean tool is selected, dragging the map will create a rectangular selection.
Depending on the options selected in the dialog shown below, clicking the delete button will remove GPS points and/or [points of interest](../gpx) located either inside or outside the selection.
<div class="flex flex-row justify-center">
<Clean class="text-foreground p-3 border rounded-md shadow-lg" />
</div>

View File

@@ -0,0 +1,24 @@
---
title: Elevation
---
<script>
import { MountainSnow } from 'lucide-svelte';
import Elevation from '$lib/components/toolbar/tools/Elevation.svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# <MountainSnow size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
This tool allows you to add elevation data to traces and [points of interest](../gpx), or to replace the existing data.
<div class="flex flex-row justify-center">
<Elevation class="text-foreground p-3 border rounded-md shadow-lg" />
</div>
<DocsNote>
Elevation data is provided by <a href="https://mapbox.com" target="_blank">Mapbox</a>.
You can learn more about its origin and accuracy in the <a href="https://docs.mapbox.com/data/tilesets/reference/mapbox-terrain-dem-v1/#elevation-data" target="_blank">documentation</a>.
</DocsNote>

View File

@@ -0,0 +1,26 @@
---
title: Extract
---
<script>
import { Ungroup } from 'lucide-svelte';
import Extract from '$lib/components/toolbar/tools/Extract.svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# <Ungroup size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
This tool allows you to extract [tracks (or segments)](../gpx) from files (or tracks) containing multiple of them.
<div class="flex flex-row justify-center">
<Extract class="text-foreground p-3 border rounded-md shadow-lg" />
</div>
Applying the tool to a file containing multiple tracks will create a new file for each of the tracks it contains.
Similarly, applying the tool to a track containing multiple segments will create (in the same file) a new track for each of the segments it contains.
<DocsNote>
When extracting the tracks from a file containing <a href="../gpx">points of interest</a>, the tool will automatically assign each point of interest to the track it is closest to.
</DocsNote>

View File

@@ -0,0 +1,20 @@
---
title: Merge
---
<script>
import { Group } from 'lucide-svelte';
import Merge from '$lib/components/toolbar/tools/Merge.svelte';
</script>
# <Group size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
To use this tool, you need to [select](../files-and-stats) multiple files, [tracks, or segments](../gpx).
- If your goal is to create a single continuous trace from your selection, use the **Connect the traces** option and validate.
- The second option can be used to create or manage files with multiple [tracks or segments](../gpx).
Merging files (or tracks) will result in a single file (or track) containing all tracks (or segments) from the selection.
<div class="flex flex-row justify-center">
<Merge class="text-foreground p-3 border rounded-md shadow-lg" />
</div>

View File

@@ -0,0 +1,26 @@
---
title: Minify
---
<script>
import { Filter } from 'lucide-svelte';
import Reduce from '$lib/components/toolbar/tools/Reduce.svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# <Filter size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
This tool can be used to reduce the number of GPS points in a trace, which can be useful for decreasing its size.
You can adjust the tolerance of the simplification algorithm using the slider, and see the number of points that will be kept, as well as the simplified trace on the map.
<div class="flex flex-row justify-center">
<Reduce class="text-foreground p-3 border rounded-md shadow-lg" />
</div>
<DocsNote>
The tolerance value represents the maximum distance allowed between the original trace and the simplified trace.
You can read more about the algorithm used <a href="https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm" target="_blank">here</a>.
</DocsNote>

View File

@@ -0,0 +1,27 @@
---
title: Points of interest
---
<script>
import { MapPin } from 'lucide-svelte';
import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
</script>
# <MapPin size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
[Points of interest](../gpx) can be added to GPX files to mark locations of interest on the map and display them on your GPS device.
### Creating a point of interest
To create a point of interest, fill in the form shown below.
You can choose the location of the point of interest either by clicking on the map or by entering the coordinates manually.
Validate the form when you are done.
<div class="flex flex-row justify-center">
<Waypoint class="text-foreground p-3 border rounded-md shadow-lg" />
</div>
### Editing a point of interest
The form above can also be used to edit an existing point of interest after selecting it on the map.
If you only need to move the point of interest, you can drag it to the desired location.

View File

@@ -0,0 +1,84 @@
---
title: Route planning and editing
---
<script>
import { Pencil, Route, Bike, TriangleAlert, ArrowRightLeft, Home, Repeat, Trash2, CirclePlay, SquareArrowUpLeft } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
import DocsImage from '$lib/components/docs/DocsImage.svelte';
</script>
# <Pencil size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
The route planning and editing tool allows you to create and edit routes by placing or moving anchor points on the map.
## Settings
As shown below, the tool dialog contains a few settings to control the routing behavior.
You can minimize the dialog to save space by clicking on <button><SquareArrowUpLeft size="16" class="inline-block" style="margin-bottom: 2px" /></button>.
<div class="flex flex-row justify-center">
<Routing minimizable={false} class="text-foreground p-3 border rounded-md shadow-lg" />
</div>
### <Route size="16" class="inline-block" style="margin-bottom: 2px" /> Routing
When routing is enabled, anchor points placed or moved on the map will be connected by a route calculated on the <a href="https://www.openstreetmap.org" target="_blank">OpenStreetMap</a> road network.
Disable routing to connect anchor points with straight lines.
This setting can also be toggled by pressing <kbd>F5</kbd>.
### <Bike size="16" class="inline-block" style="margin-bottom: 2px" /> Activity
Select the activity type to tailor the routes for.
### <TriangleAlert size="16" class="inline-block" style="margin-bottom: 2px" /> Allow private roads
When enabled, the routing engine will consider private roads when computing routes.
<DocsNote type="warning">
Only use this option if you have local knowledge of the area and have permission to use the roads in question.
</DocsNote>
## Plotting and editing routes
Creating a route or extending an existing one is as simple as clicking on the map to place a new anchor point.
You can also drag an existing anchor point to reroute the segment connecting it with the previous and next anchor point.
Furthermore, new anchor points can be inserted between existing ones by hovering over the segment connecting them and dragging the anchor point that appears to the desired location.
On touch devices, you can tap on the segment to insert a new anchor point.
<DocsNote>
When editing imported GPX files, an initial set of anchor points is created automatically.
To ease the editing process, the more the map is zoomed in, the more anchor points are displayed.
This allows the route to be edited at different levels of detail.
</DocsNote>
Finally, you can delete anchor points by clicking on them and selecting <button><Trash2 size="16" class="inline-block" style="margin-bottom: 4px" /> Delete</button> from the context menu.
<DocsImage src="tools/routing" alt="Anchor points allow you to easily edit a route." />
## Additional tools
The following tools automate some common route modification operations.
### <ArrowRightLeft size="16" class="inline-block" style="margin-bottom: 2px" /> Reverse
Reverse the direction of the route.
### <Home 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.
### <Repeat size="16" class="inline-block" style="margin-bottom: 2px" /> Round trip
Return to the starting point by the same route.
### <CirclePlay size="16" class="inline-block" style="margin-bottom: 2px" /> Change the start of the loop
When the end point of the route is close enough to the start, you can change the start of the loop by clicking on any anchor point and selecting <button><CirclePlay size="16" class="inline-block" style="margin-bottom: 2px" /> Start loop here</button> from the context menu.

View File

@@ -0,0 +1,32 @@
---
title: Crop and split
---
<script>
import { ScissorsIcon } from 'lucide-svelte';
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import DocsImage from '$lib/components/docs/DocsImage.svelte';
</script>
# <ScissorsIcon size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
## Crop
Using the slider, you can define the part of the selected trace that you want to keep.
The start and end markers on the map and the [statistics and elevation profile](../files-and-stats) are updated in real time to reflect the selection.
Alternatively, you can drag a selection rectangle directly on the elevation profile.
Validate the selection when you are satisfied with the result.
<div class="flex flex-row justify-center">
<Scissors class="text-foreground p-3 border rounded-md shadow-lg" />
</div>
## Split
To split the selected trace into two parts, click on one of the split markers displayed along the trace.
To split at a specific point of your choice, hover over the trace on the map.
Scissors will appear at the cursor position, showing that you can split the trace at that point.
You can choose to split the trace into two GPX files, or to keep the split parts in the same file as [tracks or segments](../gpx).
<DocsImage src="tools/split" alt="Hovering over the selected trace turns your cursor into scissors." />

View File

@@ -0,0 +1,27 @@
---
title: Time
---
<script>
import { CalendarClock } from 'lucide-svelte';
import Time from '$lib/components/toolbar/tools/Time.svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# <CalendarClock size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
This tool allows you to change or add timestamps to a trace.
You simply need to use the form shown below and validate it when you are done.
<div class="flex flex-row justify-center">
<Time class="text-foreground p-3 border rounded-md shadow-lg" />
</div>
When you edit the speed, the moving time is adapted accordingly in the form, and vice versa.
Similarly, when you edit the start time, the end time is updated to keep the same total duration, and vice versa.
<DocsNote>
When using this tool with existing timestamps, changing the time or speed will simply shift, stretch, or compress them accordingly.
</DocsNote>

View File

@@ -0,0 +1,35 @@
---
title: FAQ
---
<script>
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
### Do I need to donate to use the website?
No.
The website is free to use and always will be (as long as it is financially sustainable).
However, donations are appreciated and help keep the website running.
### Why is this route chosen over that one? _Or_ how can I add something to the map?
**gpx.studio** uses data from <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a>, which is an open and collaborative world map.
This means you can contribute to the map by adding or editing data on OpenStreetMap.
If you have never contributed to OpenStreetMap before, here is how you can suggest changes:
1. Go to the location where you want to add or edit data on the <a href="https://www.openstreetmap.org/" target="_blank">map</a>.
2. Use the <button>Query features</button> tool on the right to inspect the existing data.
3. Right-click on the location and select <button>Add a note here</button>.
4. Explain what is incorrect or missing in the note and click <button>Add note</button> to submit it.
Someone more experienced with OpenStreetMap will then review your note and make the necessary changes.
<DocsNote>
More information on how to contribute to OpenStreetMap can be found <a href="https://wiki.openstreetmap.org/wiki/How_to_contribute" target="_blank">here</a>.
</DocsNote>

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