194 Commits

Author SHA1 Message Date
vcoppe
15f7aea300 preserve timestamps option 2024-10-21 22:56:18 +02:00
vcoppe
4ada271ad3 try to detect 512px custom tiles 2024-10-17 11:55:13 +02:00
vcoppe
1bda957778 scrollable poi content, closes #124 2024-10-14 19:00:05 +02:00
vcoppe
95328db1ee fix poi empty name 2024-10-14 18:42:14 +02:00
vcoppe
65cbf5e751 fix updating overlay opacity 2024-10-14 16:42:05 +02:00
vcoppe
60f24f8757 add h1's 2024-10-10 11:39:14 +02:00
vcoppe
d1e4588813 distance markers hierarchy 2024-10-08 16:58:04 +02:00
vcoppe
711825f5a3 refactor map popups and add inspect trackpoint feature 2024-10-08 15:49:14 +02:00
vcoppe
d823f44558 more precise polyfill 2024-10-08 12:39:00 +02:00
vcoppe
193c77e51a fix xml output for osm attributes 2024-10-08 12:04:07 +02:00
vcoppe
45f6c405c0 fix elevation profile drag on retina screens 2024-10-06 20:03:35 +02:00
vcoppe
78e1ea1e59 New Crowdin updates (#131)
* 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 (Spanish)

* New translations en.json (Dutch)

* New translations en.json (Portuguese, Brazilian)
2024-10-05 17:43:44 +02:00
vcoppe
ca51e1d788 fix derive file style in constructor 2024-10-05 17:40:25 +02:00
vcoppe
72b0b5a706 add spanish satellite layer 2024-10-05 17:06:17 +02:00
vcoppe
4d1de97ba5 New Crowdin updates (#130)
* 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 (Belarusian)

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

* New translations en.json (Danish)

* New translations en.json (Latvian)

* Update source file en.json

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Dutch)

* New translations en.json (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 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 en.json (Korean)

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

* New translations en.json (Hebrew)

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

* New translations en.json (Finnish)

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

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

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

* New translations en.json (Belarusian)

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

* New translations en.json (Danish)

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

* New translations en.json (Latvian)

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

* Update source file en.json

* Update source file files-and-stats.mdx

* New translations en.json (Spanish)

* New translations en.json (Spanish)

* New translations en.json (Spanish)

* New translations en.json (Spanish)

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

* New translations en.json (French)

* New translations en.json (Dutch)

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

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

* New translations en.json (Danish)

* New translations en.json (Latvian)

* New translations en.json (French)

* New translations en.json (Dutch)

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

* New translations files-and-stats.mdx (Dutch)
2024-10-04 19:53:31 +02:00
vcoppe
04769002d0 update screenshot 2024-10-04 19:53:03 +02:00
vcoppe
8190284148 correct highway naming 2024-10-04 18:10:16 +02:00
vcoppe
a668b4be62 improve highway names 2024-10-04 18:04:20 +02:00
vcoppe
87534468d2 highway, sac_scale and mtb:scale coloring 2024-10-04 17:32:05 +02:00
vcoppe
d7a02f714a add highway info to elevation profile, closes #65 2024-10-03 18:14:01 +02:00
vcoppe
0c16ddd534 fix z-indices 2024-10-03 14:19:51 +02:00
vcoppe
a96f989199 small ui improvements around elevation profile 2024-10-03 14:16:41 +02:00
vcoppe
35c7c9d965 compact elevation profile dataset control 2024-10-03 13:24:23 +02:00
vcoppe
efa05b93ce remove additional scales 2024-10-03 11:41:08 +02:00
vcoppe
0e298cd0e4 same surface tags as brouter 2024-10-03 10:12:10 +02:00
vcoppe
3ef98b2110 improve mapillary integration, closes #127 2024-10-02 18:52:02 +02:00
vcoppe
fbf93ed6f9 increase wpt popup max width 2024-10-02 17:03:59 +02:00
vcoppe
1bd56b6505 New Crowdin updates (#121)
* New translations en.json (Hungarian)

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

* New translations en.json (Hungarian)

* New translations funding.mdx (Hungarian)

* New translations integration.mdx (Hungarian)

* New translations faq.mdx (Hungarian)

* New translations en.json (Hungarian)

* New translations file.mdx (Italian)

* New translations file.mdx (Italian)

* New translations en.json (Italian)

* New translations file.mdx (Italian)

* New translations en.json (German)

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

* New translations extract.mdx (German)

* New translations en.json (German)

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

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

* New translations en.json (Danish)

* New translations en.json (Latvian)

* New translations en.json (Hungarian)

* New translations menu.mdx (Hungarian)

* New translations toolbar.mdx (Hungarian)

* New translations en.json (Italian)
2024-10-02 12:53:50 +02:00
vcoppe
acf0750ccb temporary fix for #129 2024-10-02 12:49:08 +02:00
vcoppe
48eaa344e4 put ui elements below popups 2024-10-01 16:59:20 +02:00
vcoppe
3262dec7d3 show popup after content has been rendered, fixes popup placement, see #124 2024-10-01 16:56:13 +02:00
vcoppe
572d206c2c reduce hiding distance for popups 2024-10-01 14:18:23 +02:00
vcoppe
11934e5825 option to remove time gaps when merging 2024-10-01 13:17:39 +02:00
vcoppe
5cca106d18 fix button event propagation 2024-09-30 22:08:54 +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
560 changed files with 25709 additions and 6433 deletions

View File

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

View File

@@ -3,11 +3,11 @@
<img alt="Logo of gpx.studio." src="website/static/logo.svg">
</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)
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
@@ -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
- [brouter](https://github.com/abrensch/brouter) — routing engine
- [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

72
gpx/package-lock.json generated
View File

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

View File

@@ -11,16 +11,17 @@
},
"private": true,
"dependencies": {
"fast-xml-parser": "^4.4.0",
"fast-xml-parser": "^4.5.0",
"immer": "^10.1.1",
"ts-node": "^10.9.2"
},
"scripts": {
"build": "tsc"
},
"devDependencies": {
"@types/geojson": "^7946.0.14",
"@types/node": "^20.14.6",
"typescript": "^5.4.5"
"@types/node": "^20.16.10",
"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 getStatistics(): GPXStatistics;
abstract getSegments(): TrackSegment[];
abstract getTrackPoints(): TrackPoint[];
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());
}
getTrackPoints(): TrackPoint[] {
return this.children.flatMap((child) => child.getTrackPoints());
}
// Producers
_reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date) {
let og = getOriginal(this);
@@ -99,7 +104,7 @@ abstract class GPXTreeLeaf extends GPXTreeElement<GPXTreeLeaf> {
}
// A class that represents a GPX file
export class GPXFile extends GPXTreeNode<Track>{
export class GPXFile extends GPXTreeNode<Track> {
[immerable] = true;
attributes: GPXFileAttributes;
@@ -112,7 +117,15 @@ export class GPXFile extends GPXTreeNode<Track>{
super();
if (gpx) {
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.trk = gpx.trk ? gpx.trk.map((track) => new Track(track)) : [];
if (gpx.rte && gpx.rte.length > 0) {
@@ -120,22 +133,21 @@ export class GPXFile extends GPXTreeNode<Track>{
}
if (gpx.hasOwnProperty('_data')) {
this._data = gpx._data;
if (!this._data.hasOwnProperty('style')) {
let style = this.getStyle();
let fileStyle = {};
if (style.color.length === 1) {
fileStyle['color'] = style.color[0];
}
if (style.weight.length === 1) {
fileStyle['weight'] = style.weight[0];
}
if (style.opacity.length === 1) {
fileStyle['opacity'] = style.opacity[0];
}
if (Object.keys(fileStyle).length > 0) {
this.setStyle(fileStyle);
}
}
if (!this._data.hasOwnProperty('style')) {
let style = this.getStyle();
let fileStyle = {};
if (style.color.length === 1) {
fileStyle['color'] = style.color[0];
}
if (style.weight.length === 1) {
fileStyle['weight'] = style.weight[0];
}
if (style.opacity.length === 1) {
fileStyle['opacity'] = style.opacity[0];
}
if (Object.keys(fileStyle).length > 0) {
this.setStyle(fileStyle);
}
}
} else {
@@ -350,6 +362,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) {
this.trk.forEach((track) => {
track.setStyle(style);
@@ -422,8 +464,8 @@ export class Track extends GPXTreeNode<TrackSegment> {
src?: string;
link?: Link;
type?: string;
trkseg: TrackSegment[];
extensions?: TrackExtensions;
trkseg: TrackSegment[];
constructor(track?: TrackType & { _data?: any } | Track) {
super();
@@ -456,8 +498,8 @@ export class Track extends GPXTreeNode<TrackSegment> {
src: this.src,
link: cloneJSON(this.link),
type: this.type,
trkseg: this.trkseg.map((seg) => seg.clone()),
extensions: cloneJSON(this.extensions),
trkseg: this.trkseg.map((seg) => seg.clone()),
_data: cloneJSON(this._data),
});
}
@@ -501,8 +543,8 @@ export class Track extends GPXTreeNode<TrackSegment> {
src: this.src,
link: this.link,
type: this.type,
trkseg: this.trkseg.map((seg) => seg.toTrackSegmentType(exclude)),
extensions: this.extensions,
trkseg: this.trkseg.map((seg) => seg.toTrackSegmentType(exclude)),
};
}
@@ -581,6 +623,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) {
if (!this.extensions) {
this.extensions = {};
@@ -699,6 +752,11 @@ export class TrackSegment extends GPXTreeLeaf {
// 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"]) {
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);
@@ -709,17 +767,24 @@ 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.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"]) {
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.count++;
}
}
if (i > 0 && points[i - 1].extensions && points[i - 1].extensions["gpxtpx:TrackPointExtension"] && points[i - 1].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"]) {
Object.entries(points[i - 1].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"]).forEach(([key, value]) => {
if (statistics.global.extensions[key] === undefined) {
statistics.global.extensions[key] = {};
}
if (statistics.global.extensions[key][value] === undefined) {
statistics.global.extensions[key][value] = 0;
}
statistics.global.extensions[key][value] += dist;
});
}
}
[statistics.local.slope.segment, statistics.local.slope.length] = this._computeSlopeSegments(statistics);
@@ -753,29 +818,7 @@ export class TrackSegment extends GPXTreeLeaf {
}
_computeSlopeSegments(statistics: GPXStatistics): [number[], number[]] {
// 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
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 simplified = ramerDouglasPeucker(this.trkpt, 20, getElevationDistanceFunction(statistics));
let slope = [];
let length = [];
@@ -784,7 +827,7 @@ export class TrackSegment extends GPXTreeLeaf {
let start = simplified[i].point._data.index;
let end = simplified[i + 1].point._data.index;
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++) {
slope.push(0.1 * ele / dist);
@@ -821,6 +864,10 @@ export class TrackSegment extends GPXTreeLeaf {
return [this];
}
getTrackPoints(): TrackPoint[] {
return this.trkpt;
}
toGeoJSON(): GeoJSON.Feature {
return {
type: "Feature",
@@ -846,27 +893,50 @@ export class TrackSegment extends GPXTreeLeaf {
}
// Producers
replaceTrackPoints(start: number, end: number, points: TrackPoint[], speed?: number, startTime?: Date) {
replaceTrackPoints(start: number, end: number, points: TrackPoint[], speed?: number, startTime?: Date, removeGaps?: boolean) {
let og = getOriginal(this); // Read as much as possible from the original object because it is faster
let trkpt = og.trkpt.slice();
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) {
// Add timestamps to points before [start, end] because they are missing
trkpt.splice(0, 0, ...withTimestamps(trkpt.splice(0, start), speed, undefined, startTime));
}
if (points.length > 0) {
// Adapt timestamps of the new points
let last = start > 0 ? trkpt[start - 1] : 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);
} 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);
} else if (last !== undefined && removeGaps) {
// Remove gaps between the new points and the previous point
if (last.getLatitude() === points[0].getLatitude() && last.getLongitude() === points[0].getLongitude()) {
// Same point, make the new points start at its timestamp and remove the first point
if (points[0].time > last.time) {
points = withShiftedAndCompressedTimestamps(points, speed, 1, last).slice(1);
}
} else {
// Different points, make the new points start one second after the previous point
if (points[0].time.getTime() - last.time.getTime() > 1000) {
let artificialLast = points[0].clone();
artificialLast.time = new Date(last.time.getTime() + 1000);
points = withShiftedAndCompressedTimestamps(points, speed, 1, artificialLast);
}
}
}
}
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;
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));
} 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));
}
}
@@ -944,6 +1014,14 @@ export class TrackSegment extends GPXTreeLeaf {
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) {
this._data.hidden = hidden;
}
@@ -984,6 +1062,10 @@ export class TrackPoint {
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 {
return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:hr'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:hr'] : undefined;
}
@@ -992,19 +1074,14 @@ export class TrackPoint {
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 {
return this.extensions && this.extensions["gpxpx:PowerExtension"] && this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] ? this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] : undefined;
}
getSurface(): string {
return this.extensions && this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"].surface ? this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"].surface : undefined;
}
setSurface(surface: string): void {
setExtensions(extensions: Record<string, string>) {
if (Object.keys(extensions).length === 0) {
return;
}
if (!this.extensions) {
this.extensions = {};
}
@@ -1014,7 +1091,13 @@ export class TrackPoint {
if (!this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"]) {
this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"] = {};
}
this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"]["surface"] = surface;
Object.entries(extensions).forEach(([key, value]) => {
this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"][key] = value;
});
}
getExtensions(): Record<string, string> {
return this.extensions && this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"] ? this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"] : {};
}
toTrackPointType(exclude: string[] = []): TrackPointType {
@@ -1032,20 +1115,23 @@ export class TrackPoint {
"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')) {
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')) {
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')) {
trkpt.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] = this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"];
}
if (this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"].surface && !exclude.includes('surface')) {
trkpt.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"] = { surface: this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"].surface };
if (this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"] && !exclude.includes('extensions')) {
trkpt.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"] = {};
Object.entries(this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"]).forEach(([key, value]) => {
trkpt.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"][key] = value;
});
}
}
return trkpt;
@@ -1108,20 +1194,31 @@ export class Waypoint {
}
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')) {
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 {
@@ -1168,6 +1265,10 @@ export class GPXStatistics {
southWest: Coordinates,
northEast: Coordinates,
},
atemp: {
avg: number,
count: number,
},
hr: {
avg: number,
count: number,
@@ -1176,14 +1277,11 @@ export class GPXStatistics {
avg: number,
count: number,
},
atemp: {
avg: number,
count: number,
},
power: {
avg: number,
count: number,
}
},
extensions: Record<string, Record<string, number>>,
};
local: {
points: TrackPoint[],
@@ -1238,6 +1336,10 @@ export class GPXStatistics {
lon: -180,
},
},
atemp: {
avg: 0,
count: 0,
},
hr: {
avg: 0,
count: 0,
@@ -1246,14 +1348,11 @@ export class GPXStatistics {
avg: 0,
count: 0,
},
atemp: {
avg: 0,
count: 0,
},
power: {
avg: 0,
count: 0,
}
},
extensions: {},
};
this.local = {
points: [],
@@ -1315,17 +1414,39 @@ export class GPXStatistics {
this.global.bounds.northEast.lat = Math.max(this.global.bounds.northEast.lat, other.global.bounds.northEast.lat);
this.global.bounds.northEast.lon = Math.max(this.global.bounds.northEast.lon, other.global.bounds.northEast.lon);
this.global.atemp.avg = (this.global.atemp.count * this.global.atemp.avg + other.global.atemp.count * other.global.atemp.avg) / Math.max(1, this.global.atemp.count + other.global.atemp.count);
this.global.atemp.count += other.global.atemp.count;
this.global.hr.avg = (this.global.hr.count * this.global.hr.avg + other.global.hr.count * other.global.hr.avg) / Math.max(1, this.global.hr.count + other.global.hr.count);
this.global.hr.count += other.global.hr.count;
this.global.cad.avg = (this.global.cad.count * this.global.cad.avg + other.global.cad.count * other.global.cad.avg) / Math.max(1, this.global.cad.count + other.global.cad.count);
this.global.cad.count += other.global.cad.count;
this.global.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.count += other.global.power.count;
Object.keys(other.global.extensions).forEach((extension) => {
if (this.global.extensions[extension] === undefined) {
this.global.extensions[extension] = {};
}
Object.keys(other.global.extensions[extension]).forEach((value) => {
if (this.global.extensions[extension][value] === undefined) {
this.global.extensions[extension][value] = 0;
}
this.global.extensions[extension][value] += other.global.extensions[extension][value];
});
});
}
slice(start: number, end: number): GPXStatistics {
if (start < 0) {
start = 0;
} else if (start >= this.local.points.length) {
return new GPXStatistics();
}
if (end < start) {
return new GPXStatistics();
} else if (end >= this.local.points.length) {
end = this.local.points.length - 1;
}
let statistics = new GPXStatistics();
statistics.local.points = this.local.points.slice(start, end + 1);
@@ -1350,9 +1471,9 @@ export class GPXStatistics {
statistics.global.bounds.northEast.lat = this.global.bounds.northEast.lat;
statistics.global.bounds.northEast.lon = this.global.bounds.northEast.lon;
statistics.global.atemp = this.global.atemp;
statistics.global.hr = this.global.hr;
statistics.global.cad = this.global.cad;
statistics.global.atemp = this.global.atemp;
statistics.global.power = this.global.power;
return statistics;
@@ -1360,7 +1481,13 @@ export class GPXStatistics {
}
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 lat1 = coord1.lat * rad;
const lat2 = coord2.lat * rad;
@@ -1369,6 +1496,30 @@ export function distance(coord1: Coordinates, coord2: Coordinates): number {
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[] {
let result = [];
@@ -1412,9 +1563,39 @@ function withTimestamps(points: TrackPoint[], speed: number, lastPoint: TrackPoi
function withShiftedAndCompressedTimestamps(points: TrackPoint[], speed: number, ratio: number, lastPoint: TrackPoint): TrackPoint[] {
let start = getTimestamp(lastPoint, points[0], speed);
let last = points[0];
return points.map((point) => {
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;
});
}
@@ -1445,8 +1626,8 @@ function convertRouteToTrack(route: RouteType): Track {
src: route.src,
link: route.link,
type: route.type,
trkseg: [],
extensions: route.extensions,
trkseg: [],
});
if (route.rtept) {
@@ -1462,6 +1643,8 @@ function convertRouteToTrack(route: RouteType): Track {
} else {
segment.trkpt.push(new TrackPoint({
attributes: rpt.attributes,
ele: rpt.ele,
time: rpt.time,
}));
}
});
@@ -1470,4 +1653,4 @@ function convertRouteToTrack(route: RouteType): Track {
}
return track;
}
}

View File

@@ -34,7 +34,7 @@ export function parseGPX(gpxData: string): GPXFile {
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);
}
@@ -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:gpxpx'] = 'http://www.garmin.com/xmlschemas/PowerExtension/v1';
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 === '')) {
gpx.trk[0].name = gpx.metadata.name;
@@ -107,6 +99,20 @@ export function buildGPX(file: GPXFile, exclude: string[]): string {
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.
return Math.atan2(Math.sin(lonB - lonA) * Math.cos(latB),
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA));
}
export function projectedPoint(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;
link?: Link;
type?: string;
trkseg: TrackSegmentType[];
extensions?: TrackExtensions;
trkseg: TrackSegmentType[];
};
export type TrackExtensions = {
@@ -89,12 +89,10 @@ export type TrackPointExtensions = {
};
export type TrackPointExtension = {
'gpxtpx:atemp'?: number;
'gpxtpx:hr'?: number;
'gpxtpx:cad'?: number;
'gpxtpx:atemp'?: number;
'gpxtpx:Extensions'?: {
surface?: string;
};
'gpxtpx:Extensions'?: Record<string, string>;
}
export type PowerExtension = {

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

View File

@@ -1,10 +1,10 @@
<!doctype html>
<html lang="en">
<html>
<head>
<meta charset="utf-8" />
<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%
</head>

View File

@@ -8,7 +8,7 @@
--foreground: 222.2 84% 4.9%;
--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-foreground: 222.2 84% 4.9%;
@@ -32,6 +32,8 @@
--destructive-foreground: 210 40% 98%;
--support: 220 15 130;
--link: 0 110 180;
--ring: 222.2 84% 4.9%;
@@ -67,6 +69,8 @@
--destructive-foreground: 210 40% 98%;
--support: 255 110 190;
--link: 80 190 255;
--ring: hsl(212.7,26.8%,83.9);
}

View File

@@ -0,0 +1,53 @@
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 response = await resolve(event, {
transformPageChunk: ({ html }) => html.replace('<html>', htmlTag).replace('<head>', headTag),
});
return response;
}

View File

@@ -0,0 +1,161 @@
export const surfaceColors: { [key: string]: string } = {
"missing": "#d1d1d1",
"paved": "#8c8c8c",
"unpaved": "#6b443a",
"asphalt": "#8c8c8c",
"concrete": "#8c8c8c",
"cobblestone": "#ffd991",
"paving_stones": "#8c8c8c",
"sett": "#ffd991",
"metal": "#8c8c8c",
"wood": "#6b443a",
"compacted": "#ffffa8",
"fine_gravel": "#ffffa8",
"gravel": "#ffffa8",
"pebblestone": "#ffffa8",
"rock": "#ffd991",
"dirt": "#ffffa8",
"ground": "#6b443a",
"earth": "#6b443a",
"mud": "#6b443a",
"sand": "#ffffc4",
"grass": "#61b55c",
"grass_paver": "#61b55c",
"clay": "#6b443a",
"stone": "#ffd991",
};
export function getSurfaceColor(surface: string): string {
return surfaceColors[surface] ? surfaceColors[surface] : surfaceColors.missing;
}
export const highwayColors: { [key: string]: string } = {
"missing": "#d1d1d1",
"motorway": "#ff4d33",
"motorway_link": "#ff4d33",
"trunk": "#ff5e4d",
"trunk_link": "#ff947f",
"primary": "#ff6e5c",
"primary_link": "#ff6e5c",
"secondary": "#ff8d7b",
"secondary_link": "#ff8d7b",
"tertiary": "#ffd75f",
"tertiary_link": "#ffd75f",
"unclassified": "#f1f2a5",
"road": "#f1f2a5",
"residential": "#73b2ff",
"living_street": "#73b2ff",
"service": "#9c9cd9",
"track": "#a8e381",
"footway": "#a8e381",
"path": "#a8e381",
"pedestrian": "#a8e381",
"cycleway": "#9de2ff",
"construction": "#e09a4a",
"bridleway": "#946f43",
"raceway": "#ff0000",
"rest_area": "#9c9cd9",
"services": "#9c9cd9",
"corridor": "#474747",
"elevator": "#474747",
"steps": "#474747",
"bus_stop": "#8545a3",
"busway": "#8545a3",
"via_ferrata": "#474747"
};
export const sacScaleColors: { [key: string]: string } = {
"hiking": "#007700",
"mountain_hiking": "#1843ad",
"demanding_mountain_hiking": "#ffff00",
"alpine_hiking": "#ff9233",
"demanding_alpine_hiking": "#ff0000",
"difficult_alpine_hiking": "#000000",
};
export const mtbScaleColors: { [key: string]: string } = {
"0-": "#007700",
"0": "#007700",
"0+": "#007700",
"1-": "#1843ad",
"1": "#1843ad",
"1+": "#1843ad",
"2-": "#ffff00",
"2": "#ffff00",
"2+": "#ffff00",
"3": "#ff0000",
"4": "#00ff00",
"5": "#000000",
"6": "#b105eb",
};
function createPattern(backgroundColor: string, sacScaleColor: string | undefined, mtbScaleColor: string | undefined, size: number = 16, lineWidth: number = 4) {
let canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
let ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, size, size);
ctx.lineWidth = lineWidth;
const halfSize = size / 2;
const halfLineWidth = lineWidth / 2;
if (sacScaleColor) {
ctx.strokeStyle = sacScaleColor;
ctx.beginPath();
ctx.moveTo(halfSize - halfLineWidth, - halfLineWidth);
ctx.lineTo(size + halfLineWidth, halfSize + halfLineWidth);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(- halfLineWidth, halfSize - halfLineWidth);
ctx.lineTo(halfSize + halfLineWidth, size + halfLineWidth);
ctx.stroke();
}
if (mtbScaleColor) {
ctx.strokeStyle = mtbScaleColor;
ctx.beginPath();
ctx.moveTo(halfSize - halfLineWidth, size + halfLineWidth);
ctx.lineTo(size + halfLineWidth, halfSize - halfLineWidth);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(- halfLineWidth, halfSize + halfLineWidth);
ctx.lineTo(halfSize + halfLineWidth, - halfLineWidth);
ctx.stroke();
}
}
return ctx?.createPattern(canvas, 'repeat') || backgroundColor;
}
const patterns: Record<string, string | CanvasPattern> = {};
export function getHighwayColor(highway: string, sacScale: string | undefined, mtbScale: string | undefined) {
let backgroundColor = highwayColors[highway] ? highwayColors[highway] : highwayColors.missing;
let sacScaleColor = sacScale ? sacScaleColors[sacScale] : undefined;
let mtbScaleColor = mtbScale ? mtbScaleColors[mtbScale] : undefined;
if (sacScale || mtbScale) {
let patternId = `${backgroundColor}-${[sacScale, mtbScale].filter(x => x).join('-')}`;
if (!patterns[patternId]) {
patterns[patternId] = createPattern(backgroundColor, sacScaleColor, mtbScaleColor);
}
return patterns[patternId];
}
return backgroundColor;
}
const maxSlope = 20;
export function getSlopeColor(slope: number): string {
if (slope > maxSlope) {
slope = maxSlope;
} else if (slope < -maxSlope) {
slope = -maxSlope;
}
let v = slope / maxSlope;
v = 1 / (1 + Math.exp(-6 * v));
v = v - 0.5;
let hue = ((0.5 - v) * 120).toString(10);
let lightness = 90 - Math.abs(v) * 70;
return `hsl(${hue},70%,${lightness}%)`;
}

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.0 MiB

View File

@@ -1,11 +1,11 @@
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 { type AnySourceData, type Style } from 'mapbox-gl';
import { type StyleSpecification } from 'mapbox-gl';
import ignFrTopo from './custom/ign-fr-topo.json';
import ignFrPlan from './custom/ign-fr-plan.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 | StyleSpecification; } = {
mapboxOutdoors: 'mapbox://styles/mapbox/outdoors-v12',
mapboxSatellite: 'mapbox://styles/mapbox/satellite-streets-v12',
openStreetMap: {
@@ -15,7 +15,7 @@ export const basemaps: { [key: string]: string | Style; } = {
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'],
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>'
}
},
@@ -66,7 +66,7 @@ export const basemaps: { [key: string]: string | Style; } = {
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'],
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>'
}
},
@@ -158,7 +158,7 @@ export const basemaps: { [key: string]: string | Style; } = {
tiles: ['https://www.ign.es/wmts/mapa-raster?layer=MTN&style=default&tilematrixset=GoogleMapsCompatible&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/jpeg&TileMatrix={z}&TileCol={x}&TileRow={y}'],
tileSize: 256,
maxzoom: 20,
attribution: 'IGN-F/Géoportail'
attribution: '&copy; <a href="https://www.ign.es" target="_blank">IGN</a>'
}
},
layers: [{
@@ -167,23 +167,24 @@ export const basemaps: { [key: string]: string | Style; } = {
source: 'ignEs',
}],
},
ordnanceSurvey: {
ignEsSatellite: {
version: 8,
sources: {
ordnanceSurvey: {
ignEsSatellite: {
type: 'raster',
tiles: ['https://api.os.uk/maps/raster/v1/zxy/Outdoor_3857/{z}/{x}/{y}.png?key=piCT8WysfuC3xLSUW7sGLfrAAJoYDvQz'],
tiles: ['https://www.ign.es/wmts/pnoa-ma?layer=OI.OrthoimageCoverage&style=default&tilematrixset=GoogleMapsCompatible&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/jpeg&TileMatrix={z}&TileCol={x}&TileRow={y}'],
tileSize: 256,
maxzoom: 20,
attribution: '&copy; <a href="http://www.ordnancesurvey.co.uk/" target="_blank">Ordnance Survey</a>'
attribution: '&copy; <a href="https://www.ign.es" target="_blank">IGN</a>'
}
},
layers: [{
id: 'ordnanceSurvey',
id: 'ignEsSatellite',
type: 'raster',
source: 'ordnanceSurvey',
source: 'ignEsSatellite',
}],
},
ordnanceSurvey: "https://api.os.uk/maps/vector/v1/vts/resources/styles?srs=3857&key=piCT8WysfuC3xLSUW7sGLfrAAJoYDvQz",
norwayTopo: {
version: 8,
sources: {
@@ -204,18 +205,49 @@ export const basemaps: { [key: string]: string | Style; } = {
swedenTopo: {
version: 8,
sources: {
swedenTopo: {
swedenTopoWMTS: {
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'],
tileSize: 256,
maxzoom: 14,
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: [{
id: 'swedenTopo',
id: 'swedenTopoWMTS',
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: {
@@ -271,144 +303,309 @@ export const basemaps: { [key: string]: string | Style; } = {
},
};
export function extendBasemap(basemap: string | Style): 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; } = {
export const overlays: { [key: string]: string | StyleSpecification; } = {
cyclOSMlite: {
type: 'raster',
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>'
version: 8,
sources: {
cyclOSMlite: {
type: 'raster',
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: {
type: 'raster',
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>',
version: 8,
sources: {
swisstopoSlope: {
type: 'raster',
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: {
type: 'raster',
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>'
version: 8,
sources: {
swisstopoHiking: {
type: 'raster',
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: {
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>'
version: 8,
sources: {
swisstopoHikingClosures: {
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: {
type: 'raster',
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>'
version: 8,
sources: {
swisstopoCycling: {
type: 'raster',
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: {
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>'
version: 8,
sources: {
swisstopoCyclingClosures: {
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: {
type: 'raster',
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>'
version: 8,
sources: {
swisstopoMountainBike: {
type: 'raster',
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: {
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>'
version: 8,
sources: {
swisstopoMountainBikeClosures: {
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: {
type: 'raster',
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>'
version: 8,
sources: {
swisstopoSkiTouring: {
type: 'raster',
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: {
type: 'raster',
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'
version: 8,
sources: {
ignFrCadastre: {
type: 'raster',
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: {
type: 'raster',
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,
maxzoom: 17,
attribution: 'IGN-F/Géoportail'
version: 8,
sources: {
ignSlope: {
type: 'raster',
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: {
type: 'raster',
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'
version: 8,
sources: {
ignSkiTouring: {
type: 'raster',
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: {
type: 'raster',
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>'
version: 8,
sources: {
waymarkedTrailsHiking: {
type: 'raster',
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: {
type: 'raster',
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>'
version: 8,
sources: {
waymarkedTrailsCycling: {
type: 'raster',
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: {
type: 'raster',
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>'
version: 8,
sources: {
waymarkedTrailsMTB: {
type: 'raster',
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: {
type: 'raster',
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>'
version: 8,
sources: {
waymarkedTrailsSkating: {
type: 'raster',
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: {
type: 'raster',
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>'
version: 8,
sources: {
waymarkedTrailsHorseRiding: {
type: 'raster',
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: {
type: 'raster',
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>'
version: 8,
sources: {
waymarkedTrailsWinter: {
type: 'raster',
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',
}],
},
};
@@ -456,9 +653,11 @@ export const basemapTree: LayerTreeType = {
},
spain: {
ignEs: true,
ignEsSatellite: true,
},
sweden: {
swedenTopo: true,
swedenSatellite: true,
},
switzerland: {
swisstopoRaster: true,
@@ -479,9 +678,6 @@ export const basemapTree: LayerTreeType = {
export const overlayTree: LayerTreeType = {
overlays: {
world: {
cyclOSM: {
cyclOSMlite: true,
},
waymarked_trails: {
waymarkedTrailsHiking: true,
waymarkedTrailsCycling: true,
@@ -489,7 +685,9 @@ export const overlayTree: LayerTreeType = {
waymarkedTrailsSkating: true,
waymarkedTrailsHorseRiding: true,
waymarkedTrailsWinter: true,
}
},
cyclOSMlite: true,
bikerouterGravel: true,
},
countries: {
france: {
@@ -560,12 +758,9 @@ export const overpassTree: LayerTreeType = {
export const defaultBasemap = 'mapboxOutdoors';
// Default overlays used (none)
export const defaultOverlays = {
export const defaultOverlays: LayerTreeType = {
overlays: {
world: {
cyclOSM: {
cyclOSMlite: false,
},
waymarked_trails: {
waymarkedTrailsHiking: false,
waymarkedTrailsCycling: false,
@@ -573,7 +768,9 @@ export const defaultOverlays = {
waymarkedTrailsSkating: false,
waymarkedTrailsHorseRiding: false,
waymarkedTrailsWinter: false,
}
},
cyclOSMlite: false,
bikerouterGravel: false,
},
countries: {
france: {
@@ -676,9 +873,11 @@ export const defaultBasemapTree: LayerTreeType = {
},
spain: {
ignEs: false,
ignEsSatellite: false,
},
sweden: {
swedenTopo: false,
swedenSatellite: false,
},
switzerland: {
swisstopoRaster: false,
@@ -699,9 +898,6 @@ export const defaultBasemapTree: LayerTreeType = {
export const defaultOverlayTree: LayerTreeType = {
overlays: {
world: {
cyclOSM: {
cyclOSMlite: false,
},
waymarked_trails: {
waymarkedTrailsHiking: true,
waymarkedTrailsCycling: true,
@@ -709,7 +905,9 @@ export const defaultOverlayTree: LayerTreeType = {
waymarkedTrailsSkating: false,
waymarkedTrailsHorseRiding: false,
waymarkedTrailsWinter: false,
}
},
cyclOSMlite: false,
bikerouterGravel: false,
},
countries: {
france: {

View File

@@ -1,31 +0,0 @@
export const surfaceColors: { [key: string]: string } = {
'missing': '#d1d1d1',
'paved': '#8c8c8c',
'unpaved': '#6b443a',
'asphalt': '#8c8c8c',
'concrete': '#8c8c8c',
'chipseal': '#8c8c8c',
'cobblestone': '#ffd991',
'unhewn_cobblestone': '#ffd991',
'paving_stones': '#8c8c8c',
'stepping_stones': '#c7b2db',
'sett': '#ffd991',
'metal': '#8c8c8c',
'wood': '#6b443a',
'compacted': '#ffffa8',
'fine_gravel': '#ffffa8',
'gravel': '#ffffa8',
'pebblestone': '#ffffa8',
'rock': '#ffd991',
'dirt': '#ffffa8',
'ground': '#6b443a',
'earth': '#6b443a',
'snow': '#bdfffc',
'ice': '#bdfffc',
'salt': '#b6c0f2',
'mud': '#6b443a',
'sand': '#ffffc4',
'woodchips': '#6b443a',
'grass': '#61b55c',
'grass_paver': '#61b55c'
}

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

View File

@@ -1,9 +1,10 @@
<script lang="ts">
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
import * as Popover from '$lib/components/ui/popover';
import * as ToggleGroup from '$lib/components/ui/toggle-group';
import Tooltip from '$lib/components/Tooltip.svelte';
import Chart from 'chart.js/auto';
import mapboxgl from 'mapbox-gl';
import { hoveredTrackPoint, map } from '$lib/stores';
import { map } from '$lib/stores';
import { onDestroy, onMount } from 'svelte';
import {
BrickWall,
@@ -12,12 +13,15 @@
Orbit,
SquareActivity,
Thermometer,
Zap
Zap,
Circle,
Check,
ChartNoAxesColumn,
Construction
} from 'lucide-svelte';
import { surfaceColors } from '$lib/assets/surfaces';
import { _, locale } from 'svelte-i18n';
import { getSlopeColor, getSurfaceColor, getHighwayColor } from '$lib/assets/colors';
import { _ } from 'svelte-i18n';
import {
getCadenceUnits,
getCadenceWithUnits,
getConvertedDistance,
getConvertedElevation,
@@ -26,45 +30,26 @@
getDistanceUnits,
getDistanceWithUnits,
getElevationWithUnits,
getHeartRateUnits,
getHeartRateWithUnits,
getPowerUnits,
getPowerWithUnits,
getTemperatureUnits,
getTemperatureWithUnits,
getVelocityUnits,
getVelocityWithUnits,
secondsToHHMMSS
getVelocityWithUnits
} from '$lib/units';
import type { Writable } from 'svelte/store';
import { DateFormatter } from '@internationalized/date';
import type { GPXStatistics } from 'gpx';
import { settings } from '$lib/db';
import { mode } from 'mode-watcher';
import { df } from '$lib/utils';
export let gpxStatistics: Writable<GPXStatistics>;
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
export let panelSize: number;
export let additionalDatasets: string[];
export let elevationFill: 'slope' | 'surface' | undefined;
export let elevationFill: 'slope' | 'surface' | 'highway' | undefined;
export let showControls: boolean = true;
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
let df: DateFormatter;
$: if ($locale) {
df = new DateFormatter($locale, {
dateStyle: 'medium',
timeStyle: 'medium'
});
}
let canvas: HTMLCanvasElement;
let showAdditionalScales = true;
let updateShowAdditionalScales = () => {
showAdditionalScales = canvas.width / window.devicePixelRatio >= 600;
};
let overlay: HTMLCanvasElement;
let chart: Chart;
@@ -83,12 +68,11 @@
x: {
type: 'linear',
ticks: {
callback: function (value: number, index: number, ticks: { value: number }[]) {
if (index === ticks.length - 1) {
return `${value.toFixed(1).replace(/\.0+$/, '')}`;
}
callback: function (value: number) {
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
}
},
align: 'inner',
maxRotation: 0
}
},
y: {
@@ -104,7 +88,8 @@
line: {
pointRadius: 0,
tension: 0.4,
borderWidth: 2
borderWidth: 2,
cubicInterpolationMode: 'monotone'
}
},
interaction: {
@@ -127,7 +112,7 @@
},
label: function (context: Chart.TooltipContext) {
let point = context.raw;
if (context.datasetIndex === 0 && $hoveredTrackPoint === undefined) {
if (context.datasetIndex === 0) {
if ($map && marker) {
if (dragging) {
marker.remove();
@@ -158,7 +143,10 @@
segment: point.slope.segment.toFixed(1),
length: getDistanceWithUnits(point.slope.length)
};
let surface = point.surface ? point.surface : 'unknown';
let surface = point.extensions.surface ? point.extensions.surface : 'unknown';
let highway = point.extensions.highway ? point.extensions.highway : 'unknown';
let sacScale = point.extensions.sac_scale;
let mtbScale = point.extensions.mtb_scale;
let labels = [
` ${$_('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
@@ -171,6 +159,17 @@
);
}
if (elevationFill === 'highway') {
labels.push(
` ${$_('quantities.highway')}: ${$_(`toolbar.routing.highway.${highway}`)}${
sacScale ? ` (${$_(`toolbar.routing.sac_scale.${sacScale}`)})` : ''
}`
);
if (mtbScale) {
labels.push(` ${$_('toolbar.routing.mtb_scale')}: ${mtbScale}`);
}
}
if (point.time) {
labels.push(` ${$_('quantities.time')}: ${df.format(point.time)}`);
}
@@ -225,72 +224,21 @@
stacked: false,
onResize: function () {
updateOverlay();
updateShowAdditionalScales();
}
};
let datasets: {
[key: string]: {
id: string;
getLabel: () => string;
getUnits: () => string;
};
} = {
speed: {
id: 'speed',
getLabel: () => ($velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')),
getUnits: () => getVelocityUnits()
},
hr: {
id: 'hr',
getLabel: () => $_('quantities.heartrate'),
getUnits: () => getHeartRateUnits()
},
cad: {
id: 'cad',
getLabel: () => $_('quantities.cadence'),
getUnits: () => getCadenceUnits()
},
atemp: {
id: 'atemp',
getLabel: () => $_('quantities.temperature'),
getUnits: () => getTemperatureUnits()
},
power: {
id: 'power',
getLabel: () => $_('quantities.power'),
getUnits: () => getPowerUnits()
}
};
for (let [id, dataset] of Object.entries(datasets)) {
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
datasets.forEach((id) => {
options.scales[`y${id}`] = {
type: 'linear',
position: 'right',
title: {
display: true,
text: dataset.getLabel() + ' (' + dataset.getUnits() + ')',
padding: {
top: 6,
bottom: 0
}
},
grid: {
display: false
},
reverse: () => id === 'speed' && $velocityUnits === 'pace',
display: false
};
}
options.scales.yspeed['ticks'] = {
callback: function (value: number) {
if ($velocityUnits === 'speed') {
return value;
} else {
return secondsToHHMMSS(value);
}
}
};
});
onMount(async () => {
Chart.register((await import('chartjs-plugin-zoom')).default); // dynamic import to avoid SSR and 'window is not defined' error
@@ -323,8 +271,6 @@
element
});
updateShowAdditionalScales();
let startIndex = 0;
let endIndex = 0;
function getIndex(evt) {
@@ -413,7 +359,7 @@
segment: data.local.slope.segment[index],
length: data.local.slope.length[index]
},
surface: point.getSurface(),
extensions: point.getExtensions(),
coordinates: point.getCoordinates(),
index: index
};
@@ -423,7 +369,6 @@
order: 1
};
chart.data.datasets[1] = {
label: datasets.speed.getLabel(),
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
@@ -432,11 +377,10 @@
};
}),
normalized: true,
yAxisID: `y${datasets.speed.id}`,
yAxisID: 'yspeed',
hidden: true
};
chart.data.datasets[2] = {
label: datasets.hr.getLabel(),
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
@@ -445,11 +389,10 @@
};
}),
normalized: true,
yAxisID: `y${datasets.hr.id}`,
yAxisID: 'yhr',
hidden: true
};
chart.data.datasets[3] = {
label: datasets.cad.getLabel(),
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
@@ -458,11 +401,10 @@
};
}),
normalized: true,
yAxisID: `y${datasets.cad.id}`,
yAxisID: 'ycad',
hidden: true
};
chart.data.datasets[4] = {
label: datasets.atemp.getLabel(),
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
@@ -471,11 +413,10 @@
};
}),
normalized: true,
yAxisID: `y${datasets.atemp.id}`,
yAxisID: 'yatemp',
hidden: true
};
chart.data.datasets[5] = {
label: datasets.power.getLabel(),
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
@@ -484,43 +425,29 @@
};
}),
normalized: true,
yAxisID: `y${datasets.power.id}`,
yAxisID: 'ypower',
hidden: true
};
chart.options.scales.x['min'] = 0;
chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
// update units
for (let [id, dataset] of Object.entries(datasets)) {
chart.options.scales[`y${id}`].title.text =
dataset.getLabel() + ' (' + dataset.getUnits() + ')';
}
chart.update();
}
let maxSlope = 20;
function slopeFillCallback(context) {
let slope = context.p0.raw.slope.segment;
if (slope > maxSlope) {
slope = maxSlope;
} else if (slope < -maxSlope) {
slope = -maxSlope;
}
let v = slope / maxSlope;
v = 1 / (1 + Math.exp(-6 * v));
v = v - 0.5;
let hue = ((0.5 - v) * 120).toString(10);
let lightness = 90 - Math.abs(v) * 70;
return ['hsl(', hue, ',70%,', lightness, '%)'].join('');
return getSlopeColor(context.p0.raw.slope.segment);
}
function surfaceFillCallback(context) {
let surface = context.p0.raw.surface;
return surfaceColors[surface] ? surfaceColors[surface] : surfaceColors.missing;
return getSurfaceColor(context.p0.raw.extensions.surface);
}
function highwayFillCallback(context) {
return getHighwayColor(
context.p0.raw.extensions.highway,
context.p0.raw.extensions.sac_scale,
context.p0.raw.extensions.mtb_scale
);
}
$: if (chart) {
@@ -532,6 +459,10 @@
chart.data.datasets[0]['segment'] = {
backgroundColor: surfaceFillCallback
};
} else if (elevationFill === 'highway') {
chart.data.datasets[0]['segment'] = {
backgroundColor: highwayFillCallback
};
} else {
chart.data.datasets[0]['segment'] = {};
}
@@ -551,12 +482,6 @@
chart.data.datasets[4].hidden = !includeTemperature;
chart.data.datasets[5].hidden = !includePower;
}
chart.options.scales[`y${datasets.speed.id}`].display = includeSpeed && showAdditionalScales;
chart.options.scales[`y${datasets.hr.id}`].display = includeHeartRate && showAdditionalScales;
chart.options.scales[`y${datasets.cad.id}`].display = includeCadence && showAdditionalScales;
chart.options.scales[`y${datasets.atemp.id}`].display =
includeTemperature && showAdditionalScales;
chart.options.scales[`y${datasets.power.id}`].display = includePower && showAdditionalScales;
chart.update();
}
@@ -567,6 +492,8 @@
overlay.width = canvas.width / window.devicePixelRatio;
overlay.height = canvas.height / window.devicePixelRatio;
overlay.style.width = `${overlay.width}px`;
overlay.style.height = `${overlay.height}px`;
if ($slicedGPXStatistics) {
let startIndex = $slicedGPXStatistics[1];
@@ -590,7 +517,7 @@
startPixel,
chart.chartArea.top,
endPixel - startPixel,
chart.chartArea.bottom - chart.chartArea.top
chart.chartArea.height
);
}
} else if (overlay) {
@@ -603,32 +530,6 @@
$: $slicedGPXStatistics, $mode, updateOverlay();
$: if (chart) {
if ($hoveredTrackPoint) {
let index = chart._metasets[0].data.findIndex(
(point) =>
$gpxStatistics.local.points[point.raw.index]._data.index ===
$hoveredTrackPoint.point._data.index &&
$hoveredTrackPoint.point.getLongitude() === point.raw.coordinates.lon &&
$hoveredTrackPoint.point.getLatitude() === point.raw.coordinates.lat
);
if (index >= 0) {
chart.tooltip?.setActiveElements(
[
{
datasetIndex: 0,
index
}
],
{ x: 0, y: 0 }
);
}
} else {
chart.tooltip?.setActiveElements([], { x: 0, y: 0 });
}
chart.update();
}
onDestroy(() => {
if (chart) {
chart.destroy();
@@ -636,73 +537,135 @@
});
</script>
<div class="h-full grow min-w-0 flex flex-row gap-4 items-center {$$props.class ?? ''}">
<div class="grow h-full min-w-0 relative">
<canvas bind:this={overlay} class=" w-full h-full absolute pointer-events-none"></canvas>
<canvas bind:this={canvas} class="w-full h-full"></canvas>
</div>
<div class="h-full grow min-w-0 relative py-2">
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas>
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas>
{#if showControls}
<div class="h-full flex flex-col justify-center" style="width: {panelSize > 158 ? 22 : 42}px">
<ToggleGroup.Root
class="{panelSize > 158
? 'flex-col'
: 'flex-row'} flex-wrap gap-0 min-h-0 content-center border rounded-t-md"
type="single"
bind:value={elevationFill}
>
<ToggleGroup.Item class="p-0 w-5 h-5" value="slope">
<Tooltip side="left">
<TriangleRight slot="data" size="15" />
<span slot="tooltip">{$_('chart.show_slope')}</span>
</Tooltip>
</ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-5 h-5" value="surface">
<Tooltip side="left">
<BrickWall slot="data" size="15" />
<span slot="tooltip">{$_('chart.show_surface')}</span>
</Tooltip>
</ToggleGroup.Item>
</ToggleGroup.Root>
<ToggleGroup.Root
class="{panelSize > 158
? 'flex-col'
: 'flex-row'} flex-wrap gap-0 min-h-0 content-center border rounded-b-md -mt-[1px]"
type="multiple"
bind:value={additionalDatasets}
>
<ToggleGroup.Item class="p-0 w-5 h-5" value="speed">
<Tooltip side="left">
<Zap slot="data" size="15" />
<span slot="tooltip"
>{$velocityUnits === 'speed' ? $_('chart.show_speed') : $_('chart.show_pace')}</span
<div class="absolute bottom-10 right-1.5">
<Popover.Root>
<Popover.Trigger asChild let:builder>
<ButtonWithTooltip
label={$_('chart.settings')}
builders={[builder]}
variant="outline"
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 hover:bg-background"
>
<ChartNoAxesColumn size="18" />
</ButtonWithTooltip>
</Popover.Trigger>
<Popover.Content class="w-fit p-0 flex flex-col divide-y" side="top" sideOffset={-32}>
<ToggleGroup.Root
class="flex flex-col items-start gap-0 p-1"
type="single"
bind:value={elevationFill}
>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="slope"
>
</Tooltip>
</ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-5 h-5" value="hr">
<Tooltip side="left">
<HeartPulse slot="data" size="15" />
<span slot="tooltip">{$_('chart.show_heartrate')}</span>
</Tooltip>
</ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-5 h-5" value="cad">
<Tooltip side="left">
<Orbit slot="data" size="15" />
<span slot="tooltip">{$_('chart.show_cadence')}</span>
</Tooltip>
</ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-5 h-5" value="atemp">
<Tooltip side="left">
<Thermometer slot="data" size="15" />
<span slot="tooltip">{$_('chart.show_temperature')}</span>
</Tooltip>
</ToggleGroup.Item>
<ToggleGroup.Item class="p-0 w-5 h-5" value="power">
<Tooltip side="left">
<SquareActivity slot="data" size="15" />
<span slot="tooltip">{$_('chart.show_power')}</span>
</Tooltip>
</ToggleGroup.Item>
</ToggleGroup.Root>
<div class="w-6 flex justify-center items-center">
{#if elevationFill === 'slope'}
<Circle class="h-1.5 w-1.5 fill-current text-current" />
{/if}
</div>
<TriangleRight size="15" class="mr-1" />
{$_('quantities.slope')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="surface"
variant="outline"
>
<div class="w-6 flex justify-center items-center">
{#if elevationFill === 'surface'}
<Circle class="h-1.5 w-1.5 fill-current text-current" />
{/if}
</div>
<BrickWall size="15" class="mr-1" />
{$_('quantities.surface')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="highway"
variant="outline"
>
<div class="w-6 flex justify-center items-center">
{#if elevationFill === 'highway'}
<Circle class="h-1.5 w-1.5 fill-current text-current" />
{/if}
</div>
<Construction size="15" class="mr-1" />
{$_('quantities.highway')}
</ToggleGroup.Item>
</ToggleGroup.Root>
<ToggleGroup.Root
class="flex flex-col items-start gap-0 p-1"
type="multiple"
bind:value={additionalDatasets}
>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="speed"
>
<div class="w-6 flex justify-center items-center">
{#if additionalDatasets.includes('speed')}
<Check size="14" />
{/if}
</div>
<Zap size="15" class="mr-1" />
{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="hr"
>
<div class="w-6 flex justify-center items-center">
{#if additionalDatasets.includes('hr')}
<Check size="14" />
{/if}
</div>
<HeartPulse size="15" class="mr-1" />
{$_('quantities.heartrate')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="cad"
>
<div class="w-6 flex justify-center items-center">
{#if additionalDatasets.includes('cad')}
<Check size="14" />
{/if}
</div>
<Orbit size="15" class="mr-1" />
{$_('quantities.cadence')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="atemp"
>
<div class="w-6 flex justify-center items-center">
{#if additionalDatasets.includes('atemp')}
<Check size="14" />
{/if}
</div>
<Thermometer size="15" class="mr-1" />
{$_('quantities.temperature')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="power"
>
<div class="w-6 flex justify-center items-center">
{#if additionalDatasets.includes('power')}
<Check size="14" />
{/if}
</div>
<SquareActivity size="15" class="mr-1" />
{$_('quantities.power')}
</ToggleGroup.Item>
</ToggleGroup.Root>
</Popover.Content>
</Popover.Root>
</div>
{/if}
</div>

View File

@@ -16,7 +16,7 @@
import {
Download,
Zap,
BrickWall,
Earth,
HeartPulse,
Orbit,
Thermometer,
@@ -31,19 +31,19 @@
let open = false;
let exportOptions: Record<string, boolean> = {
time: true,
surface: true,
hr: true,
cad: true,
atemp: true,
power: true
power: true,
extensions: true
};
let hide: Record<string, boolean> = {
time: false,
surface: false,
hr: false,
cad: false,
atemp: false,
power: false
power: false,
extensions: false
};
$: if ($exportState !== ExportState.NONE) {
@@ -67,6 +67,7 @@
hide.cad = statistics.global.cad.count === 0;
hide.atemp = statistics.global.atemp.count === 0;
hide.power = statistics.global.power.count === 0;
hide.extensions = Object.keys(statistics.global.extensions).length === 0;
}
$: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]);
@@ -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"
>
<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 class="max-w-96 text-sm">
<span class="max-w-[80%] text-sm">
{$_('menu.support_message')}
</span>
</div>
@@ -119,7 +120,11 @@
{/if}
</Button>
</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="grow">
<Separator />
@@ -139,11 +144,11 @@
{$_('quantities.time')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5">
<Checkbox id="export-surface" bind:checked={exportOptions.surface} />
<Label for="export-surface" class="flex flex-row items-center gap-1">
<BrickWall size="16" />
{$_('quantities.surface')}
<div class="flex flex-row items-center gap-1.5 {hide.extensions ? 'hidden' : ''}">
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
<Label for="export-extensions" class="flex flex-row items-center gap-1">
<Earth size="16" />
{$_('quantities.osm_extensions')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">

View File

@@ -11,7 +11,7 @@
<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="grow flex flex-col items-start">
<Logo class="h-8" />
<Logo class="h-8" width="153" />
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
@@ -52,6 +52,15 @@
</div>
<div class="flex flex-col items-start gap-1" id="contact">
<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
variant="link"
class="h-6 px-0 text-muted-foreground"

View File

@@ -28,7 +28,7 @@
<Card.Root
class="h-full {orientation === 'vertical'
? 'min-w-44 sm:min-w-52 text-sm sm:text-base'
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
: 'w-full'} border-none shadow-none"
>
<Card.Content
@@ -36,48 +36,46 @@
? 'flex-col justify-center'
: 'flex-row w-full justify-between'} gap-4 p-0"
>
<Tooltip>
<span slot="data" class="flex flex-row items-center">
<Ruler size="18" class="mr-1" />
<Tooltip label={$_('quantities.distance')}>
<span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" />
<WithUnits value={statistics.global.distance.total} type="distance" />
</span>
<span slot="tooltip">{$_('quantities.distance')}</span>
</Tooltip>
<Tooltip>
<span slot="data" class="flex flex-row items-center">
<MoveUpRight size="18" class="mr-1" />
<Tooltip label={$_('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
<MoveDownRight size="18" class="mx-1" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
</span>
<span slot="tooltip">{$_('quantities.elevation')}</span>
</Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'}
<Tooltip class={orientation === 'horizontal' ? 'hidden xs:block' : ''}>
<span slot="data" class="flex flex-row items-center">
<Zap size="18" class="mr-1" />
<Tooltip
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="16" class="mr-1" />
<WithUnits value={statistics.global.speed.moving} type="speed" showUnits={false} />
<span class="mx-1">/</span>
<WithUnits value={statistics.global.speed.total} type="speed" />
</span>
<span slot="tooltip"
>{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_(
'quantities.moving'
)} / {$_('quantities.total')})</span
>
</Tooltip>
{/if}
{#if panelSize > 160 || orientation === 'horizontal'}
<Tooltip class={orientation === 'horizontal' ? 'hidden md:block' : ''}>
<span slot="data" class="flex flex-row items-center">
<Timer size="18" class="mr-1" />
<Tooltip
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_('quantities.total')})"
>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.global.time.moving} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.global.time.total} type="time" />
</span>
<span slot="tooltip"
>{$_('quantities.time')} ({$_('quantities.moving')} / {$_('quantities.total')})</span
>
</Tooltip>
{/if}
</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;
</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" />
<div>
<slot />
{#if link}
<a
href={link}
target="_blank"
class="text-sm text-blue-500 dark:text-blue-300 hover:underline"
>
<a href={link} target="_blank" class="text-sm text-link hover:underline">
{$_('menu.more')}
</a>
{/if}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import * as Select from '$lib/components/ui/select';
import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils';
@@ -19,24 +20,32 @@
</script>
<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" />
<Select.Value class="ml-2 mr-auto" />
</Select.Trigger>
<Select.Content>
{#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang)}>
<Select.Item value={lang}>{label}</Select.Item>
</a>
{#if $page.url.pathname.includes('404')}
<a href={getURLForLanguage(lang, '/')}>
<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}
</Select.Content>
</Select.Root>
<!-- hidden links for svelte crawling -->
<div class="hidden">
{#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang)}>
{label}
</a>
{/each}
{#if !$page.url.pathname.includes('404')}
{#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang, $page.url.pathname)}>
{label}
</a>
{/each}
{/if}
</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"
/></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}

View File

@@ -50,9 +50,53 @@
language = 'en';
}
const loadJson = mapboxgl.Style.prototype._load;
mapboxgl.Style.prototype._load = function (json, validate) {
if (
json['sources'] &&
json['sources']['mapbox-satellite'] &&
json['sources']['mapbox-satellite']['data'] &&
json['sources']['mapbox-satellite']['data']['data']
) {
// Temporary fix for https://github.com/gpxstudio/gpx.studio/issues/129
delete json['sources']['mapbox-satellite']['data']['data'];
}
loadJson.call(this, json, validate);
};
let newMap = new mapboxgl.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,
hash: hash,
language,
@@ -62,6 +106,7 @@
});
newMap.on('load', () => {
$map = newMap; // only set the store after the map has loaded
window._map = newMap; // entry point for extensions
scaleControl.setUnit($distanceUnits);
});
@@ -78,15 +123,42 @@
);
if (geocoder) {
newMap.addControl(
new MapboxGeocoder({
accessToken: mapboxgl.accessToken,
mapboxgl: mapboxgl,
collapsed: true,
flyTo: fitBoundsOptions,
language
})
);
let geocoder = new MapboxGeocoder({
mapboxgl: mapboxgl,
enableEventLogging: false,
collapsed: true,
flyTo: fitBoundsOptions,
language,
localGeocoder: () => [],
localGeocoderOnly: true,
externalGeocoder: (query: string) =>
fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
)
.then((response) => response.json())
.then((data) => {
return data.map((result: any) => {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [result.lon, result.lat]
},
place_name: result.display_name
};
});
})
});
let onKeyDown = geocoder._onKeyDown;
geocoder._onKeyDown = (e: KeyboardEvent) => {
// Trigger search on Enter key only
if (e.key === 'Enter') {
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
} else if (geocoder._typeahead.data.length > 0) {
geocoder._typeahead.clear();
}
};
newMap.addControl(geocoder);
}
if (geolocate) {
@@ -111,10 +183,12 @@
tileSize: 512,
maxzoom: 14
});
newMap.setTerrain({
source: 'mapbox-dem',
exaggeration: newMap.getPitch() > 0 ? 1 : 0
});
if (newMap.getPitch() > 0) {
newMap.setTerrain({
source: 'mapbox-dem',
exaggeration: 1
});
}
newMap.setFog({
color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)',
@@ -128,18 +202,7 @@
exaggeration: 1
});
} else {
newMap.setTerrain({
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)'
newMap.setTerrain(null);
}
});
});
@@ -285,7 +348,7 @@
div :global(.mapboxgl-popup) {
@apply w-fit;
@apply z-20;
@apply z-50;
}
div :global(.mapboxgl-popup-content) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import Logo from '$lib/components/Logo.svelte';
import { Button } from '$lib/components/ui/button';
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
import { BookOpenText, Home, Map } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n';
@@ -10,8 +11,8 @@
<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">
<a href={getURLForLanguage($locale, '/')} class="shrink-0 translate-y-0.5">
<Logo class="h-8 sm:hidden" iconOnly={true} />
<Logo class="h-8 hidden sm:block" />
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
<Logo class="h-8 hidden sm:block" width="153" />
</a>
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/')}>
<Home size="18" class="mr-1.5" />
@@ -25,6 +26,7 @@
<BookOpenText size="18" class="mr-1.5" />
{$_('menu.help')}
</Button>
<ModeSwitch class="ml-auto" />
<AlgoliaDocSearch class="ml-auto" />
<ModeSwitch class="hidden xs:block" />
</div>
</nav>

View File

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

View File

@@ -1,14 +1,18 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
export let label: string;
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
</script>
<Tooltip.Root>
<Tooltip.Trigger {...$$restProps}>
<slot name="data" />
<Tooltip.Trigger {...$$restProps} aria-label={label}>
<slot />
</Tooltip.Trigger>
<Tooltip.Content {side}>
<slot name="tooltip" />
<div class="flex flex-row items-center">
<span>{label}</span>
<slot name="extra" />
</div>
</Tooltip.Content>
</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 {
celsiusToFahrenheit,
distancePerHourToSecondsPerDistance,
kilometersToMiles,
metersToFeet,
getConvertedDistance,
getConvertedElevation,
getConvertedVelocity,
getDistanceUnits,
getElevationUnits,
getVelocityUnits,
secondsToHHMMSS
} from '$lib/units';
@@ -20,31 +23,18 @@
<span class={$$props.class}>
{#if type === 'distance'}
{#if $distanceUnits === 'metric'}
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers') : ''}
{:else}
{kilometersToMiles(value).toFixed(decimals ?? 2)} {showUnits ? $_('units.miles') : ''}
{/if}
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getDistanceUnits($distanceUnits) : ''}
{:else if type === 'elevation'}
{#if $distanceUnits === 'metric'}
{value.toFixed(decimals ?? 0)} {showUnits ? $_('units.meters') : ''}
{:else}
{metersToFeet(value).toFixed(decimals ?? 0)} {showUnits ? $_('units.feet') : ''}
{/if}
{getConvertedElevation(value, $distanceUnits).toFixed(decimals ?? 0)}
{showUnits ? getElevationUnits($distanceUnits) : ''}
{:else if type === 'speed'}
{#if $distanceUnits === 'metric'}
{#if $velocityUnits === 'speed'}
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers_per_hour') : ''}
{:else}
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(value))}
{showUnits ? $_('units.minutes_per_kilometer') : ''}
{/if}
{:else if $velocityUnits === 'speed'}
{kilometersToMiles(value).toFixed(decimals ?? 2)}
{showUnits ? $_('units.miles_per_hour') : ''}
{#if $velocityUnits === 'speed'}
{getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{:else}
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(kilometersToMiles(value)))}
{showUnits ? $_('units.minutes_per_mile') : ''}
{secondsToHHMMSS(getConvertedVelocity(value, $velocityUnits, $distanceUnits))}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{/if}
{:else if type === 'temperature'}
{#if $temperatureUnits === 'celsius'}

View File

@@ -1,42 +1,12 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { _, locale } from 'svelte-i18n';
import { _ } from 'svelte-i18n';
export let path: string;
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`);
}
}
export let module;
</script>
{#if module !== undefined}
{#if titleOnly}
{metadata.title}
{:else}
<div class="markdown flex flex-col gap-3">
<svelte:component this={module} />
</div>
{/if}
{/if}
<div class="markdown flex flex-col gap-3">
<svelte:component this={module} />
</div>
<style lang="postcss">
:global(.markdown) {
@@ -64,24 +34,24 @@
@apply pt-1.5;
}
:global(.markdown p > button) {
:global(.markdown p > button, .markdown li > button) {
@apply border;
@apply rounded-md;
@apply px-1;
}
:global(.markdown > a) {
@apply text-blue-500;
@apply text-link;
@apply hover:underline;
}
:global(.markdown p > a) {
@apply text-blue-500;
@apply text-link;
@apply hover:underline;
}
:global(.markdown li > a) {
@apply text-blue-500;
@apply text-link;
@apply hover:underline;
}

View File

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

View File

@@ -3,8 +3,8 @@
</script>
<div
class="bg-accent border-l-8 {type === 'note'
? 'border-blue-500'
class="bg-secondary border-l-8 {type === 'note'
? 'border-link'
: 'border-destructive'} p-2 text-sm rounded-md"
>
<slot />
@@ -12,7 +12,7 @@
<style lang="postcss">
div :global(a) {
@apply text-blue-500;
@apply text-link;
@apply hover:underline;
}
</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";
export const guides: Record<string, string[]> = {
'getting-started': [],
menu: ['file', 'edit', 'view', 'settings'],
'files-and-stats': [],
toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'minify', 'clean'],
toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'elevation', 'minify', 'clean'],
'map-controls': [],
'gpx': [],
'integration': [],
'faq': [],
};
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
@@ -26,11 +27,13 @@ export const guideIcons: Record<string, string | ComponentType<Icon>> = {
"time": CalendarClock,
"merge": Group,
"extract": Ungroup,
"elevation": MountainSnow,
"minify": Filter,
"clean": SquareDashedMousePointer,
"map-controls": "🗺",
"gpx": "💾",
"integration": "{ 👩‍💻 }",
"faq": "🔮",
};
export function getPreviousGuide(currentGuide: string): string | undefined {

View File

@@ -20,8 +20,13 @@
import type { GPXFile } from 'gpx';
import { selection } from '$lib/components/file-list/Selection';
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 { browser } from '$app/environment';
$embedding = true;
@@ -55,7 +60,7 @@
});
let downloads: Promise<GPXFile | null>[] = [];
options.files.forEach((url) => {
getFilesFromEmbeddingOptions(options).forEach((url) => {
downloads.push(
fetch(url)
.then((response) => response.blob())
@@ -176,7 +181,7 @@
prevSettings.theme = $mode ?? 'system';
});
$: if (options) {
$: if (browser && options) {
applyOptions();
}
@@ -224,7 +229,7 @@
geolocate={false}
hash={useHash}
/>
<OpenIn bind:files={options.files} />
<OpenIn bind:files={options.files} bind:ids={options.ids} />
<LayerControl />
<GPXLayers />
{#if $fileObservers.size > 1}
@@ -255,9 +260,7 @@
options.elevation.power ? 'power' : null
].filter((dataset) => dataset !== null)}
elevationFill={options.elevation.fill}
panelSize={options.elevation.height}
showControls={options.elevation.controls}
class="py-2"
/>
{/if}
</div>

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 = {
token: string;
files: string[];
ids: string[];
basemap: string;
elevation: {
show: boolean;
height: number,
controls: boolean,
fill: 'slope' | 'surface' | undefined,
speed: boolean,
hr: boolean,
cad: boolean,
temp: boolean,
power: boolean,
},
distanceMarkers: boolean,
directionMarkers: boolean,
distanceUnits: 'metric' | 'imperial',
velocityUnits: 'speed' | 'pace',
temperatureUnits: 'celsius' | 'fahrenheit',
theme: 'system' | 'light' | 'dark',
height: number;
controls: boolean;
fill: 'slope' | 'surface' | 'highway' | undefined;
speed: boolean;
hr: boolean;
cad: boolean;
temp: boolean;
power: boolean;
};
distanceMarkers: boolean;
directionMarkers: boolean;
distanceUnits: 'metric' | 'imperial' | 'nautical';
velocityUnits: 'speed' | 'pace';
temperatureUnits: 'celsius' | 'fahrenheit';
theme: 'system' | 'light' | 'dark';
};
export const defaultEmbeddingOptions = {
token: '',
files: [],
ids: [],
basemap: 'mapboxOutdoors',
elevation: {
show: true,
@@ -36,21 +39,24 @@ export const defaultEmbeddingOptions = {
hr: false,
cad: false,
temp: false,
power: false,
power: false
},
distanceMarkers: false,
directionMarkers: false,
distanceUnits: 'metric',
velocityUnits: 'speed',
temperatureUnits: 'celsius',
theme: 'system',
theme: 'system'
};
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
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));
for (const key in options) {
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;
}
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));
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]);
if (Object.keys(cleanedOptions[key]).length === 0) {
delete cleanedOptions[key];
@@ -77,4 +90,59 @@ export function getCleanedEmbeddingOptions(options: any, defaultOptions: any = d
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];
$: if (files) {
$: {
let urls = files.split(',');
urls = urls.filter((url) => url.length > 0);
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
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;
@@ -84,7 +92,7 @@
}
</script>
<Card.Root>
<Card.Root id="embedding-playground">
<Card.Header>
<Card.Title>{$_('embedding.title')}</Card.Title>
</Card.Header>
@@ -94,6 +102,8 @@
<Input id="token" type="text" class="h-8" bind:value={options.token} />
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
<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>
<Select.Root
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
@@ -132,7 +142,7 @@
let value = selected?.value;
if (value === 'none') {
options.elevation.fill = undefined;
} else if (value === 'slope' || value === 'surface') {
} else if (value === 'slope' || value === 'surface' || value === 'highway') {
options.elevation.fill = value;
}
}}
@@ -143,6 +153,7 @@
<Select.Content>
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item>
<Select.Item value="highway">{$_('quantities.highway')}</Select.Item>
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
</Select.Content>
</Select.Root>
@@ -155,35 +166,35 @@
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
<Label for="show-speed" class="flex flex-row items-center gap-1">
<Zap size="16" />
{$_('chart.show_speed')}
{$_('quantities.speed')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
<Label for="show-hr" class="flex flex-row items-center gap-1">
<HeartPulse size="16" />
{$_('chart.show_heartrate')}
{$_('quantities.heartrate')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
<Label for="show-cad" class="flex flex-row items-center gap-1">
<Orbit size="16" />
{$_('chart.show_cadence')}
{$_('quantities.cadence')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
<Label for="show-temp" class="flex flex-row items-center gap-1">
<Thermometer size="16" />
{$_('chart.show_temperature')}
{$_('quantities.temperature')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-power" bind:checked={options.elevation.power} />
<Label for="show-power" class="flex flex-row items-center gap-1">
<SquareActivity size="16" />
{$_('chart.show_power')}
{$_('quantities.power')}
</Label>
</div>
</div>
@@ -214,6 +225,10 @@
<RadioGroup.Item value="imperial" id="imperial" />
<Label for="imperial">{$_('menu.imperial')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="nautical" id="nautical" />
<Label for="nautical">{$_('menu.nautical')}</Label>
</div>
</RadioGroup.Root>
</Label>
<Label class="flex flex-col items-start gap-2">

View File

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

View File

@@ -1,364 +1,374 @@
<script lang="ts" context="module">
let dragging: Writable<ListLevel | null> = writable(null);
let dragging: Writable<ListLevel | null> = writable(null);
let updating = false;
let updating = false;
</script>
<script lang="ts">
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
import { afterUpdate, getContext, onDestroy, onMount } from 'svelte';
import Sortable from 'sortablejs/Sortable';
import { getFileIds, settings, type GPXFileWithStatistics } from '$lib/db';
import { get, writable, type Readable, type Writable } from 'svelte/store';
import FileListNodeStore from './FileListNodeStore.svelte';
import FileListNode from './FileListNode.svelte';
import {
ListFileItem,
ListLevel,
ListRootItem,
ListWaypointsItem,
allowedMoves,
moveItems,
type ListItem
} from './FileList';
import { selection } from './Selection';
import { _ } from 'svelte-i18n';
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
import { afterUpdate, getContext, onDestroy, onMount } from 'svelte';
import Sortable from 'sortablejs/Sortable';
import { getFileIds, settings, type GPXFileWithStatistics } from '$lib/db';
import { get, writable, type Readable, type Writable } from 'svelte/store';
import FileListNodeStore from './FileListNodeStore.svelte';
import FileListNode from './FileListNode.svelte';
import {
ListFileItem,
ListLevel,
ListRootItem,
ListWaypointsItem,
allowedMoves,
moveItems,
type ListItem
} from './FileList';
import { selection } from './Selection';
import { isMac } from '$lib/utils';
import { _ } from 'svelte-i18n';
export let node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint;
export let item: ListItem;
export let waypointRoot: boolean = false;
export let node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint;
export let item: ListItem;
export let waypointRoot: boolean = false;
let container: HTMLElement;
let elements: { [id: string]: HTMLElement } = {};
let sortableLevel: ListLevel =
node instanceof Map
? ListLevel.FILE
: node instanceof GPXFile
? waypointRoot
? ListLevel.WAYPOINTS
: item instanceof ListWaypointsItem
? ListLevel.WAYPOINT
: ListLevel.TRACK
: node instanceof Track
? ListLevel.SEGMENT
: ListLevel.WAYPOINT;
let sortable: Sortable;
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let container: HTMLElement;
let elements: { [id: string]: HTMLElement } = {};
let sortableLevel: ListLevel =
node instanceof Map
? ListLevel.FILE
: node instanceof GPXFile
? waypointRoot
? ListLevel.WAYPOINTS
: item instanceof ListWaypointsItem
? ListLevel.WAYPOINT
: ListLevel.TRACK
: node instanceof Track
? ListLevel.SEGMENT
: ListLevel.WAYPOINT;
let sortable: Sortable;
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let destroyed = false;
let lastUpdateStart = 0;
function updateToSelection(e) {
if (destroyed) {
return;
}
let destroyed = false;
let lastUpdateStart = 0;
function updateToSelection(e) {
if (destroyed) {
return;
}
lastUpdateStart = Date.now();
setTimeout(() => {
if (Date.now() - lastUpdateStart >= 40) {
if (updating) {
return;
}
lastUpdateStart = Date.now();
setTimeout(() => {
if (Date.now() - lastUpdateStart >= 40) {
if (updating) {
return;
}
updating = true;
// Sortable updates selection
let changed = getChangedIds();
if (changed.length > 0) {
selection.update(($selection) => {
$selection.clear();
Object.entries(elements).forEach(([id, element]) => {
$selection.set(
item.extend(getRealId(id)),
element.classList.contains('sortable-selected')
);
});
updating = true;
// Sortable updates selection
let changed = getChangedIds();
if (changed.length > 0) {
selection.update(($selection) => {
$selection.clear();
Object.entries(elements).forEach(([id, element]) => {
$selection.set(
item.extend(getRealId(id)),
element.classList.contains('sortable-selected')
);
});
if (
e.originalEvent &&
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
($selection.size > 1 || !$selection.has(item.extend(getRealId(changed[0]))))
) {
// Fix bug that sometimes causes a single select to be treated as a multi-select
$selection.clear();
$selection.set(item.extend(getRealId(changed[0])), true);
}
if (
e.originalEvent &&
!(
e.originalEvent.ctrlKey ||
e.originalEvent.metaKey ||
e.originalEvent.shiftKey
) &&
($selection.size > 1 ||
!$selection.has(item.extend(getRealId(changed[0]))))
) {
// Fix bug that sometimes causes a single select to be treated as a multi-select
$selection.clear();
$selection.set(item.extend(getRealId(changed[0])), true);
}
return $selection;
});
}
updating = false;
}
}, 50);
}
return $selection;
});
}
updating = false;
}
}, 50);
}
function updateFromSelection() {
if (destroyed || updating) {
return;
}
updating = true;
// Selection updates sortable
let changed = getChangedIds();
for (let id of changed) {
let element = elements[id];
if (element) {
if ($selection.has(item.extend(id))) {
Sortable.utils.select(element);
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
} else {
Sortable.utils.deselect(element);
}
}
}
updating = false;
}
function updateFromSelection() {
if (destroyed || updating) {
return;
}
updating = true;
// Selection updates sortable
let changed = getChangedIds();
for (let id of changed) {
let element = elements[id];
if (element) {
if ($selection.has(item.extend(id))) {
Sortable.utils.select(element);
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
} else {
Sortable.utils.deselect(element);
}
}
}
updating = false;
}
$: if ($selection) {
updateFromSelection();
}
$: if ($selection) {
updateFromSelection();
}
const { fileOrder } = settings;
function syncFileOrder() {
if (!sortable || sortableLevel !== ListLevel.FILE) {
return;
}
const { fileOrder } = settings;
function syncFileOrder() {
if (!sortable || sortableLevel !== ListLevel.FILE) {
return;
}
const currentOrder = sortable.toArray();
if (currentOrder.length !== $fileOrder.length) {
sortable.sort($fileOrder);
} else {
for (let i = 0; i < currentOrder.length; i++) {
if (currentOrder[i] !== $fileOrder[i]) {
sortable.sort($fileOrder);
break;
}
}
}
}
const currentOrder = sortable.toArray();
if (currentOrder.length !== $fileOrder.length) {
sortable.sort($fileOrder);
} else {
for (let i = 0; i < currentOrder.length; i++) {
if (currentOrder[i] !== $fileOrder[i]) {
sortable.sort($fileOrder);
break;
}
}
}
}
$: if ($fileOrder) {
syncFileOrder();
}
$: if ($fileOrder) {
syncFileOrder();
}
function createSortable() {
sortable = Sortable.create(container, {
group: {
name: sortableLevel,
pull: allowedMoves[sortableLevel],
put: true
},
direction: orientation,
forceAutoScrollFallback: true,
multiDrag: true,
multiDragKey: 'Meta',
avoidImplicitDeselect: true,
onSelect: updateToSelection,
onDeselect: updateToSelection,
onStart: () => {
dragging.set(sortableLevel);
},
onEnd: () => {
dragging.set(null);
},
onSort: (e) => {
if (sortableLevel === ListLevel.FILE) {
let newFileOrder = sortable.toArray();
if (newFileOrder.length !== get(fileOrder).length) {
fileOrder.set(newFileOrder);
} else {
for (let i = 0; i < newFileOrder.length; i++) {
if (newFileOrder[i] !== get(fileOrder)[i]) {
fileOrder.set(newFileOrder);
break;
}
}
}
}
function createSortable() {
sortable = Sortable.create(container, {
group: {
name: sortableLevel,
pull: allowedMoves[sortableLevel],
put: true
},
direction: orientation,
forceAutoScrollFallback: true,
multiDrag: true,
multiDragKey: isMac() ? 'Meta' : 'Ctrl',
avoidImplicitDeselect: true,
onSelect: updateToSelection,
onDeselect: updateToSelection,
onStart: () => {
dragging.set(sortableLevel);
},
onEnd: () => {
dragging.set(null);
},
onSort: (e) => {
if (sortableLevel === ListLevel.FILE) {
let newFileOrder = sortable.toArray();
if (newFileOrder.length !== get(fileOrder).length) {
fileOrder.set(newFileOrder);
} else {
for (let i = 0; i < newFileOrder.length; i++) {
if (newFileOrder[i] !== get(fileOrder)[i]) {
fileOrder.set(newFileOrder);
break;
}
}
}
}
let fromItem = Sortable.get(e.from)._item;
let toItem = Sortable.get(e.to)._item;
let fromItem = Sortable.get(e.from)._item;
let toItem = Sortable.get(e.to)._item;
if (item === toItem && !(fromItem instanceof ListRootItem)) {
// Event is triggered on source and destination list, only handle it once
let fromItems = [];
let toItems = [];
if (item === toItem && !(fromItem instanceof ListRootItem)) {
// Event is triggered on source and destination list, only handle it once
let fromItems = [];
let toItems = [];
if (Sortable.get(e.from)._waypointRoot) {
fromItems = [fromItem.extend('waypoints')];
} else {
let oldIndices: number[] =
e.oldIndicies.length > 0 ? e.oldIndicies.map((i) => i.index) : [e.oldIndex];
oldIndices = oldIndices.filter((i) => i >= 0);
oldIndices.sort((a, b) => a - b);
if (Sortable.get(e.from)._waypointRoot) {
fromItems = [fromItem.extend('waypoints')];
} else {
let oldIndices: number[] =
e.oldIndicies.length > 0
? e.oldIndicies.map((i) => i.index)
: [e.oldIndex];
oldIndices = oldIndices.filter((i) => i >= 0);
oldIndices.sort((a, b) => a - b);
fromItems = oldIndices.map((i) => fromItem.extend(i));
}
fromItems = oldIndices.map((i) => fromItem.extend(i));
}
if (Sortable.get(e.from)._waypointRoot && Sortable.get(e.to)._waypointRoot) {
toItems = [toItem.extend('waypoints')];
} else {
if (Sortable.get(e.to)._waypointRoot) {
toItem = toItem.extend('waypoints');
}
if (Sortable.get(e.from)._waypointRoot && Sortable.get(e.to)._waypointRoot) {
toItems = [toItem.extend('waypoints')];
} else {
if (Sortable.get(e.to)._waypointRoot) {
toItem = toItem.extend('waypoints');
}
let newIndices: number[] =
e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex];
newIndices = newIndices.filter((i) => i >= 0);
newIndices.sort((a, b) => a - b);
let newIndices: number[] =
e.newIndicies.length > 0
? e.newIndicies.map((i) => i.index)
: [e.newIndex];
newIndices = newIndices.filter((i) => i >= 0);
newIndices.sort((a, b) => a - b);
if (toItem instanceof ListRootItem) {
let newFileIds = getFileIds(newIndices.length);
toItems = newIndices.map((i, index) => {
$fileOrder.splice(i, 0, newFileIds[index]);
return item.extend(newFileIds[index]);
});
} else {
toItems = newIndices.map((i) => toItem.extend(i));
}
}
if (toItem instanceof ListRootItem) {
let newFileIds = getFileIds(newIndices.length);
toItems = newIndices.map((i, index) => {
$fileOrder.splice(i, 0, newFileIds[index]);
return item.extend(newFileIds[index]);
});
} else {
toItems = newIndices.map((i) => toItem.extend(i));
}
}
moveItems(fromItem, toItem, fromItems, toItems);
}
}
});
Object.defineProperty(sortable, '_item', {
value: item,
writable: true
});
moveItems(fromItem, toItem, fromItems, toItems);
}
}
});
Object.defineProperty(sortable, '_item', {
value: item,
writable: true
});
Object.defineProperty(sortable, '_waypointRoot', {
value: waypointRoot,
writable: true
});
}
Object.defineProperty(sortable, '_waypointRoot', {
value: waypointRoot,
writable: true
});
}
onMount(() => {
createSortable();
destroyed = false;
});
onMount(() => {
createSortable();
destroyed = false;
});
afterUpdate(() => {
elements = {};
container.childNodes.forEach((element) => {
if (element instanceof HTMLElement) {
let attr = element.getAttribute('data-id');
if (attr) {
if (node instanceof Map && !node.has(attr)) {
element.remove();
} else {
elements[attr] = element;
}
}
}
});
afterUpdate(() => {
elements = {};
container.childNodes.forEach((element) => {
if (element instanceof HTMLElement) {
let attr = element.getAttribute('data-id');
if (attr) {
if (node instanceof Map && !node.has(attr)) {
element.remove();
} else {
elements[attr] = element;
}
}
}
});
syncFileOrder();
updateFromSelection();
});
syncFileOrder();
updateFromSelection();
});
onDestroy(() => {
destroyed = true;
});
onDestroy(() => {
destroyed = true;
});
function getChangedIds() {
let changed: (string | number)[] = [];
Object.entries(elements).forEach(([id, element]) => {
let realId = getRealId(id);
let realItem = item.extend(realId);
let inSelection = get(selection).has(realItem);
let isSelected = element.classList.contains('sortable-selected');
if (inSelection !== isSelected) {
changed.push(realId);
}
});
return changed;
}
function getChangedIds() {
let changed: (string | number)[] = [];
Object.entries(elements).forEach(([id, element]) => {
let realId = getRealId(id);
let realItem = item.extend(realId);
let inSelection = get(selection).has(realItem);
let isSelected = element.classList.contains('sortable-selected');
if (inSelection !== isSelected) {
changed.push(realId);
}
});
return changed;
}
function getRealId(id: string | number) {
return sortableLevel === ListLevel.FILE || sortableLevel === ListLevel.WAYPOINTS
? id
: parseInt(id);
}
function getRealId(id: string | number) {
return sortableLevel === ListLevel.FILE || sortableLevel === ListLevel.WAYPOINTS
? id
: parseInt(id);
}
$: canDrop = $dragging !== null && allowedMoves[$dragging].includes(sortableLevel);
$: canDrop = $dragging !== null && allowedMoves[$dragging].includes(sortableLevel);
</script>
<div
bind:this={container}
class="sortable {orientation} flex {orientation === 'vertical'
? 'flex-col'
: 'flex-row gap-1'} {canDrop ? 'min-h-5' : ''}"
bind:this={container}
class="sortable {orientation} flex {orientation === 'vertical'
? 'flex-col'
: 'flex-row gap-1'} {canDrop ? 'min-h-5' : ''}"
>
{#if node instanceof Map}
{#each node as [fileId, file] (fileId)}
<div data-id={fileId}>
<FileListNodeStore {file} />
</div>
{/each}
{:else if node instanceof GPXFile}
{#if item instanceof ListWaypointsItem}
{#each node.wpt as wpt, i (wpt)}
<div data-id={i} class="ml-1">
<FileListNode node={wpt} item={item.extend(i)} />
</div>
{/each}
{:else if waypointRoot}
{#if node.wpt.length > 0}
<div data-id="waypoints">
<FileListNode {node} item={item.extend('waypoints')} />
</div>
{/if}
{:else}
{#each node.children as child, i (child)}
<div data-id={i}>
<FileListNode node={child} item={item.extend(i)} />
</div>
{/each}
{/if}
{:else if node instanceof Track}
{#each node.children as child, i (child)}
<div data-id={i} class="ml-1">
<FileListNode node={child} item={item.extend(i)} />
</div>
{/each}
{/if}
{#if node instanceof Map}
{#each node as [fileId, file] (fileId)}
<div data-id={fileId}>
<FileListNodeStore {file} />
</div>
{/each}
{:else if node instanceof GPXFile}
{#if item instanceof ListWaypointsItem}
{#each node.wpt as wpt, i (wpt)}
<div data-id={i} class="ml-1">
<FileListNode node={wpt} item={item.extend(i)} />
</div>
{/each}
{:else if waypointRoot}
{#if node.wpt.length > 0}
<div data-id="waypoints">
<FileListNode {node} item={item.extend('waypoints')} />
</div>
{/if}
{:else}
{#each node.children as child, i (child)}
<div data-id={i}>
<FileListNode node={child} item={item.extend(i)} />
</div>
{/each}
{/if}
{:else if node instanceof Track}
{#each node.children as child, i (child)}
<div data-id={i} class="ml-1">
<FileListNode node={child} item={item.extend(i)} />
</div>
{/each}
{/if}
</div>
{#if node instanceof GPXFile && item instanceof ListFileItem}
{#if !waypointRoot}
<svelte:self {node} {item} waypointRoot={true} />
{/if}
{#if !waypointRoot}
<svelte:self {node} {item} waypointRoot={true} />
{/if}
{/if}
<style lang="postcss">
.sortable > div {
@apply rounded-md;
@apply h-fit;
@apply leading-none;
}
.sortable > div {
@apply rounded-md;
@apply h-fit;
@apply leading-none;
}
.vertical :global(button) {
@apply hover:bg-muted;
}
.vertical :global(button) {
@apply hover:bg-muted;
}
.vertical :global(.sortable-selected button) {
@apply hover:bg-accent;
}
.vertical :global(.sortable-selected button) {
@apply hover:bg-accent;
}
.vertical :global(.sortable-selected) {
@apply bg-accent;
}
.vertical :global(.sortable-selected) {
@apply bg-accent;
}
.horizontal :global(button) {
@apply bg-accent;
@apply hover:bg-muted;
}
.horizontal :global(button) {
@apply bg-accent;
@apply hover:bg-muted;
}
.horizontal :global(.sortable-selected button) {
@apply bg-background;
}
.horizontal :global(.sortable-selected button) {
@apply bg-background;
}
</style>

View File

@@ -15,6 +15,7 @@
EyeOff,
ClipboardCopy,
ClipboardPaste,
Maximize,
Scissors,
FileStack,
FileX
@@ -39,18 +40,20 @@
} from './Selection';
import { getContext } from 'svelte';
import { get } from 'svelte/store';
import { allHidden, editMetadata, editStyle, embedding, gpxLayers, map } from '$lib/stores';
import {
GPXTreeElement,
Track,
TrackSegment,
type AnyGPXTreeElement,
Waypoint,
GPXFile
} from 'gpx';
allHidden,
editMetadata,
editStyle,
embedding,
centerMapOnSelection,
gpxLayers,
map
} from '$lib/stores';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { _ } from 'svelte-i18n';
import MetadataDialog from './MetadataDialog.svelte';
import StyleDialog from './StyleDialog.svelte';
import { waypointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
export let item: ListItem;
@@ -170,7 +173,7 @@
if (layer && file) {
let waypoint = file.wpt[item.getWaypointIndex()];
if (waypoint) {
layer.showWaypointPopup(waypoint);
waypointPopup?.setItem({ item: waypoint, fileId: item.getFileId() });
}
}
}
@@ -179,7 +182,7 @@
if (item instanceof ListWaypointItem) {
let layer = gpxLayers.get(item.getFileId());
if (layer) {
layer.hideWaypointPopup();
waypointPopup?.setItem(null);
}
}
}}
@@ -239,10 +242,7 @@
{#if item instanceof ListFileItem}
<ContextMenu.Item
disabled={!singleSelection}
on:click={() =>
dbUtils.applyToFile(item.getFileId(), (file) =>
file.replaceTracks(file.trk.length, file.trk.length, [new Track()])
)}
on:click={() => dbUtils.addNewTrack(item.getFileId())}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_track')}
@@ -251,17 +251,7 @@
{:else if item instanceof ListTrackItem}
<ContextMenu.Item
disabled={!singleSelection}
on:click={() => {
let trackIndex = item.getTrackIndex();
dbUtils.applyToFile(item.getFileId(), (file) =>
file.replaceTrackSegments(
trackIndex,
file.trk[trackIndex].trkseg.length,
file.trk[trackIndex].trkseg.length,
[new TrackSegment()]
)
);
}}
on:click={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_segment')}
@@ -275,38 +265,41 @@
{$_('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
{/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'}
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
<Copy size="16" class="mr-1" />
{$_('menu.duplicate')}
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
<ContextMenu.Item on:click={copySelection}>
<ClipboardCopy size="16" class="mr-1" />
{$_('menu.copy')}
<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'}
<ContextMenu.Item on:click={copySelection}>
<ClipboardCopy size="16" class="mr-1" />
{$_('menu.copy')}
<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 />
<ClipboardPaste size="16" class="mr-1" />
{$_('menu.paste')}
<Shortcut key="V" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Separator />
<ContextMenu.Item on:click={dbUtils.deleteSelection}>
{#if item instanceof ListFileItem}
<FileX size="16" class="mr-1" />

View File

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

View File

@@ -1,10 +1,11 @@
import { font } from "$lib/assets/layers";
import { settings } from "$lib/db";
import { gpxStatistics } from "$lib/stores";
import { get } from "svelte/store";
const { distanceMarkers, distanceUnits, currentBasemap } = settings;
const { distanceMarkers, distanceUnits } = settings;
const stops = [[100, 0], [50, 7], [25, 8, 10], [10, 10], [5, 11], [1, 13]];
export class DistanceMarkers {
map: mapboxgl.Map;
@@ -17,7 +18,7 @@ export class DistanceMarkers {
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(distanceMarkers.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() {
@@ -32,30 +33,36 @@ export class DistanceMarkers {
data: this.getDistanceMarkersGeoJSON()
});
}
if (!this.map.getLayer('distance-markers')) {
this.map.addLayer({
id: 'distance-markers',
type: 'symbol',
source: 'distance-markers',
layout: {
'text-field': ['get', 'distance'],
'text-size': 14,
'text-font': [font[get(currentBasemap)] ?? 'Open Sans Bold'],
'text-padding': 20,
},
paint: {
'text-color': 'black',
'text-halo-width': 2,
'text-halo-color': 'white',
}
});
} else {
this.map.moveLayer('distance-markers');
}
stops.forEach(([d, minzoom, maxzoom]) => {
if (!this.map.getLayer(`distance-markers-${d}`)) {
this.map.addLayer({
id: `distance-markers-${d}`,
type: 'symbol',
source: 'distance-markers',
filter: d === 5 ? ['any', ['==', ['get', 'level'], 5], ['==', ['get', 'level'], 25]] : ['==', ['get', 'level'], d],
minzoom: minzoom,
maxzoom: maxzoom ?? 24,
layout: {
'text-field': ['get', 'distance'],
'text-size': 14,
'text-font': ['Open Sans Bold'],
},
paint: {
'text-color': 'black',
'text-halo-width': 2,
'text-halo-color': 'white',
}
});
} else {
this.map.moveLayer(`distance-markers-${d}`);
}
});
} else {
if (this.map.getLayer('distance-markers')) {
this.map.removeLayer('distance-markers');
}
stops.forEach(([d]) => {
if (this.map.getLayer(`distance-markers-${d}`)) {
this.map.removeLayer(`distance-markers-${d}`);
}
});
}
} catch (e) { // No reliable way to check if the map is ready to add sources and layers
return;
@@ -74,6 +81,7 @@ export class DistanceMarkers {
for (let i = 0; i < statistics.local.distance.total.length; i++) {
if (statistics.local.distance.total[i] >= currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)) {
let distance = currentTargetDistance.toFixed(0);
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [0, 0];
features.push({
type: 'Feature',
geometry: {
@@ -82,6 +90,8 @@ export class DistanceMarkers {
},
properties: {
distance,
level,
minzoom,
}
} as GeoJSON.Feature);
currentTargetDistance += 1;

View File

@@ -1,13 +1,11 @@
import { currentTool, hoveredTrackPoint, map, Tool } from "$lib/stores";
import { currentTool, map, Tool } from "$lib/stores";
import { settings, type GPXFileWithStatistics, dbUtils } from "$lib/db";
import { get, type Readable } from "svelte/store";
import mapboxgl from "mapbox-gl";
import { currentPopupWaypoint, deleteWaypoint, waypointPopup } from "./WaypointPopup";
import { waypointPopup, deleteWaypoint, trackpointPopup } from "./GPXLayerPopup";
import { addSelectItem, selectItem, selection } from "$lib/components/file-list/Selection";
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
import type { Waypoint } from "gpx";
import { getClosestLinePoint, getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
import { font } from "$lib/assets/layers";
import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
import { MapPin, Square } from "lucide-static";
import { getSymbolKey, symbols } from "$lib/assets/symbols";
@@ -44,6 +42,31 @@ function decrementColor(color: string) {
}
}
const inspectKey = 'Shift';
let inspectKeyDown: KeyDown | null = null;
class KeyDown {
key: string;
down: boolean = false;
constructor(key: string) {
this.key = key;
document.addEventListener('keydown', this.onKeyDown);
document.addEventListener('keyup', this.onKeyUp);
}
onKeyDown = (e: KeyboardEvent) => {
if (e.key === this.key) {
this.down = true;
}
}
onKeyUp = (e: KeyboardEvent) => {
if (e.key === this.key) {
this.down = false;
}
}
isDown() {
return this.down;
}
}
function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
@@ -66,7 +89,7 @@ function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
</svg>`;
}
const { directionMarkers, verticalFileView, currentBasemap, defaultOpacity, defaultWeight } = settings;
const { directionMarkers, verticalFileView, defaultOpacity, defaultWeight } = settings;
export class GPXLayer {
map: mapboxgl.Map;
@@ -80,10 +103,10 @@ export class GPXLayer {
updateBinded: () => void = this.update.bind(this);
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
maybeHideWaypointPopupBinded: (e: any) => void = this.maybeHideWaypointPopup.bind(this);
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
this.map = map;
@@ -113,7 +136,11 @@ export class GPXLayer {
}));
this.draggable = get(currentTool) === Tool.WAYPOINT;
this.map.on('style.load', this.updateBinded);
this.map.on('style.import.load', this.updateBinded);
if (inspectKeyDown === null) {
inspectKeyDown = new KeyDown(inspectKey);
}
}
update() {
@@ -155,9 +182,10 @@ export class GPXLayer {
});
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('mousemove', this.fileId, this.layerOnMouseMoveBinded);
this.map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
this.map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
}
if (get(directionMarkers)) {
@@ -172,7 +200,7 @@ export class GPXLayer {
'text-keep-upright': false,
'text-max-angle': 361,
'text-allow-overlap': true,
'text-font': [font[get(currentBasemap)] ?? 'Open Sans Bold'],
'text-font': ['Open Sans Bold'],
'symbol-placement': 'line',
'symbol-spacing': 20,
},
@@ -225,11 +253,11 @@ export class GPXLayer {
}).setLngLat(waypoint.getCoordinates());
Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true });
let dragEndTimestamp = 0;
marker.getElement().addEventListener('mouseover', (e) => {
marker.getElement().addEventListener('mousemove', (e) => {
if (marker._isDragging) {
return;
}
this.showWaypointPopup(marker._waypoint);
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
e.stopPropagation();
});
marker.getElement().addEventListener('click', (e) => {
@@ -252,26 +280,28 @@ export class GPXLayer {
} else if (get(currentTool) === Tool.WAYPOINT) {
selectedWaypoint.set([marker._waypoint, this.fileId]);
} else {
this.showWaypointPopup(marker._waypoint);
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
}
e.stopPropagation();
});
marker.on('dragstart', () => {
setGrabbingCursor();
marker.getElement().style.cursor = 'grabbing';
this.hideWaypointPopup();
waypointPopup?.hide();
});
marker.on('dragend', (e) => {
resetCursor();
marker.getElement().style.cursor = '';
dbUtils.applyToFile(this.fileId, (file) => {
let latLng = marker.getLngLat();
let wpt = file.wpt[marker._waypoint._data.index];
wpt.setCoordinates({
lat: latLng.lat,
lon: latLng.lng
getElevation([marker._waypoint]).then((ele) => {
dbUtils.applyToFile(this.fileId, (file) => {
let latLng = marker.getLngLat();
let wpt = file.wpt[marker._waypoint._data.index];
wpt.setCoordinates({
lat: latLng.lat,
lon: latLng.lng
});
wpt.ele = ele[0];
});
wpt.ele = getElevation(this.map, wpt.getCoordinates());
});
dragEndTimestamp = Date.now()
});
@@ -296,17 +326,18 @@ export class GPXLayer {
updateMap(map: mapboxgl.Map) {
this.map = map;
this.map.on('style.load', this.updateBinded);
this.map.on('style.import.load', this.updateBinded);
this.update();
}
remove() {
if (get(map)) {
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('mousemove', this.fileId, this.layerOnMouseMoveBinded);
this.map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
this.map.off('style.load', this.updateBinded);
this.map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
this.map.off('style.import.load', this.updateBinded);
if (this.map.getLayer(this.fileId + '-direction')) {
this.map.removeLayer(this.fileId + '-direction');
@@ -348,28 +379,21 @@ export class GPXLayer {
}
}
layerOnMouseMove(e: any) {
let trackIndex = e.features[0].properties.trackIndex;
let segmentIndex = e.features[0].properties.segmentIndex;
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
let file = get(this.file)?.file;
if (file) {
let segment = file.trk[trackIndex].trkseg[segmentIndex];
let point = getClosestLinePoint(segment.trkpt, { lat: e.lngLat.lat, lon: e.lngLat.lng });
hoveredTrackPoint.set({
fileId: this.fileId,
trackIndex,
segmentIndex,
point
});
}
}
}
layerOnMouseLeave() {
resetCursor();
hoveredTrackPoint.set(undefined);
}
layerOnMouseMove(e: any) {
if (inspectKeyDown?.isDown()) {
let trackIndex = e.features[0].properties.trackIndex;
let segmentIndex = e.features[0].properties.segmentIndex;
const file = get(this.file)?.file;
if (file) {
const closest = getClosestLinePoint(file.trk[trackIndex].trkseg[segmentIndex].trkpt, { lat: e.lngLat.lat, lon: e.lngLat.lng });
trackpointPopup?.setItem({ item: closest, fileId: this.fileId });
}
}
}
layerOnClick(e: any) {
@@ -404,40 +428,9 @@ export class GPXLayer {
}
}
showWaypointPopup(waypoint: Waypoint) {
if (get(currentPopupWaypoint) !== null) {
this.hideWaypointPopup();
}
let marker = this.markers[waypoint._data.index];
if (marker) {
currentPopupWaypoint.set([waypoint, this.fileId]);
marker.setPopup(waypointPopup);
marker.togglePopup();
this.map.on('mousemove', this.maybeHideWaypointPopupBinded);
}
}
maybeHideWaypointPopup(e: any) {
let waypoint = get(currentPopupWaypoint)?.[0];
if (waypoint) {
let marker = this.markers[waypoint._data.index];
if (marker) {
if (this.map.project(marker.getLngLat()).dist(this.map.project(e.lngLat)) > 100) {
this.hideWaypointPopup();
}
} else {
this.hideWaypointPopup();
}
}
}
hideWaypointPopup() {
let waypoint = get(currentPopupWaypoint)?.[0];
if (waypoint) {
let marker = this.markers[waypoint._data.index];
marker?.getPopup()?.remove();
currentPopupWaypoint.set(null);
this.map.off('mousemove', this.maybeHideWaypointPopupBinded);
layerOnContextMenu(e: any) {
if (e.originalEvent.ctrlKey) {
this.layerOnClick(e);
}
}

View File

@@ -0,0 +1,44 @@
import { dbUtils } from "$lib/db";
import { MapPopup } from "$lib/components/MapPopup";
export let waypointPopup: MapPopup | null = null;
export let trackpointPopup: MapPopup | null = null;
export function createPopups(map: mapboxgl.Map) {
removePopups();
waypointPopup = new MapPopup(map, {
closeButton: false,
focusAfterOpen: false,
maxWidth: undefined,
offset: {
'top': [0, 0],
'top-left': [0, 0],
'top-right': [0, 0],
'bottom': [0, -30],
'bottom-left': [0, -30],
'bottom-right': [0, -30],
'left': [10, -15],
'right': [-10, -15],
},
});
trackpointPopup = new MapPopup(map, {
closeButton: false,
focusAfterOpen: false,
maxWidth: undefined,
});
}
export function removePopups() {
if (waypointPopup !== null) {
waypointPopup.remove();
waypointPopup = null;
}
if (trackpointPopup !== null) {
trackpointPopup.remove();
trackpointPopup = null;
}
}
export function deleteWaypoint(fileId: string, waypointIndex: number) {
dbUtils.applyToFile(fileId, (file) => file.replaceWaypoints(waypointIndex, waypointIndex, []));
}

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import { map, gpxLayers } from '$lib/stores';
import { GPXLayer } from './GPXLayer';
import WaypointPopup from './WaypointPopup.svelte';
import { fileObservers } from '$lib/db';
import { DistanceMarkers } from './DistanceMarkers';
import { StartEndMarkers } from './StartEndMarkers';
import { onDestroy } from 'svelte';
import { createPopups, removePopups } from './GPXLayerPopup';
let distanceMarkers: DistanceMarkers | undefined = undefined;
let startEndMarkers: StartEndMarkers | undefined = undefined;
@@ -35,6 +35,7 @@
if (startEndMarkers) {
startEndMarkers.remove();
}
createPopups($map);
distanceMarkers = new DistanceMarkers($map);
startEndMarkers = new StartEndMarkers($map);
}
@@ -42,17 +43,14 @@
onDestroy(() => {
gpxLayers.forEach((layer) => layer.remove());
gpxLayers.clear();
removePopups();
if (distanceMarkers) {
distanceMarkers.remove();
distanceMarkers = undefined;
}
if (startEndMarkers) {
startEndMarkers.remove();
startEndMarkers = undefined;
}
});
</script>
<WaypointPopup />

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import type { TrackPoint } from 'gpx';
import type { PopupItem } from '$lib/components/MapPopup';
import * as Card from '$lib/components/ui/card';
import WithUnits from '$lib/components/WithUnits.svelte';
import { Compass, Mountain, Timer } from 'lucide-svelte';
import { df } from '$lib/utils';
export let trackpoint: PopupItem<TrackPoint>;
</script>
<Card.Root class="border-none shadow-md text-base p-2">
<Card.Header class="p-0">
<Card.Title class="text-md"></Card.Title>
</Card.Header>
<Card.Content class="flex flex-col p-0 text-xs gap-1">
<div class="flex flex-row items-center gap-1">
<Compass size="14" />
{trackpoint.item.getLatitude().toFixed(6)}&deg; {trackpoint.item
.getLongitude()
.toFixed(6)}&deg;
</div>
{#if trackpoint.item.ele !== undefined}
<div class="flex flex-row items-center gap-1">
<Mountain size="14" />
<WithUnits value={trackpoint.item.ele} type="elevation" />
</div>
{/if}
{#if trackpoint.item.time}
<div class="flex flex-row items-center gap-1">
<Timer size="14" />
{df.format(trackpoint.item.time)}
</div>
{/if}
</Card.Content>
</Card.Root>

View File

@@ -2,104 +2,101 @@
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import Shortcut from '$lib/components/Shortcut.svelte';
import { waypointPopup, currentPopupWaypoint, deleteWaypoint } from './WaypointPopup';
import { deleteWaypoint } from './GPXLayerPopup';
import WithUnits from '$lib/components/WithUnits.svelte';
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
import { onMount } from 'svelte';
import { Tool, currentTool } from '$lib/stores';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { _ } from 'svelte-i18n';
import sanitizeHtml from 'sanitize-html';
import type { Waypoint } from 'gpx';
import type { PopupItem } from '$lib/components/MapPopup';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
let popupElement: HTMLDivElement;
export let waypoint: PopupItem<Waypoint>;
onMount(() => {
waypointPopup.setDOMContent(popupElement);
popupElement.classList.remove('hidden');
});
$: symbolKey = $currentPopupWaypoint ? getSymbolKey($currentPopupWaypoint[0].sym) : undefined;
$: symbolKey = waypoint ? getSymbolKey(waypoint.item.sym) : undefined;
function sanitize(text: string | undefined): string {
if (text === undefined) {
return '';
}
let sanitized = sanitizeHtml(text, {
allowedTags: ['a', 'br'],
return sanitizeHtml(text, {
allowedTags: ['a', 'br', 'img'],
allowedAttributes: {
a: ['href', 'target']
a: ['href', 'target'],
img: ['src']
}
}).trim();
return sanitized;
}
</script>
<div bind:this={popupElement} class="hidden">
{#if $currentPopupWaypoint}
<Card.Root class="border-none shadow-md text-base max-w-80 p-2">
<Card.Header class="p-0">
<Card.Title class="text-md">
{#if $currentPopupWaypoint[0].link && $currentPopupWaypoint[0].link.attributes && $currentPopupWaypoint[0].link.attributes.href}
<a href={$currentPopupWaypoint[0].link.attributes.href} target="_blank">
{$currentPopupWaypoint[0].name ?? $currentPopupWaypoint[0].link.attributes.href}
<ExternalLink size="12" class="inline-block mb-1.5" />
</a>
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
<Card.Header class="p-0">
<Card.Title class="text-md">
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href}
<a href={waypoint.item.link.attributes.href} target="_blank">
{waypoint.item.name ?? waypoint.item.link.attributes.href}
<ExternalLink size="12" class="inline-block mb-1.5" />
</a>
{:else}
{waypoint.item.name ?? $_('gpx.waypoint')}
{/if}
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col text-sm p-0">
<div class="flex flex-row items-center text-muted-foreground text-xs whitespace-nowrap">
{#if symbolKey}
<span>
{#if symbols[symbolKey].icon}
<svelte:component
this={symbols[symbolKey].icon}
size="12"
class="inline-block mb-0.5"
/>
{:else}
{$currentPopupWaypoint[0].name ?? $_('gpx.waypoint')}
<span class="w-4 inline-block" />
{/if}
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col p-0 text-sm">
<div class="flex flex-row items-center text-muted-foreground text-xs whitespace-nowrap">
{#if symbolKey}
<span>
{#if symbols[symbolKey].icon}
<svelte:component
this={symbols[symbolKey].icon}
size="12"
class="inline-block mb-0.5"
/>
{:else}
<span class="w-4 inline-block" />
{/if}
{$_(`gpx.symbol.${symbolKey}`)}
</span>
<Dot size="16" />
{/if}
{$currentPopupWaypoint[0].getLatitude().toFixed(6)}&deg; {$currentPopupWaypoint[0]
.getLongitude()
.toFixed(6)}&deg;
{#if $currentPopupWaypoint[0].ele !== undefined}
<Dot size="16" />
<WithUnits value={$currentPopupWaypoint[0].ele} type="elevation" />
{/if}
</div>
{#if $currentPopupWaypoint[0].desc}
<span class="whitespace-pre-wrap">{@html sanitize($currentPopupWaypoint[0].desc)}</span>
{/if}
{#if $currentPopupWaypoint[0].cmt && $currentPopupWaypoint[0].cmt !== $currentPopupWaypoint[0].desc}
<span class="whitespace-pre-wrap">{@html sanitize($currentPopupWaypoint[0].cmt)}</span>
{/if}
{#if $currentTool === Tool.WAYPOINT}
<Button
class="mt-2 w-full px-2 py-1 h-8 justify-start"
variant="outline"
on:click={() =>
deleteWaypoint($currentPopupWaypoint[1], $currentPopupWaypoint[0]._data.index)}
>
<Trash2 size="16" class="mr-1" />
{$_('menu.delete')}
<Shortcut key="" shift={true} click={true} />
</Button>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>
{$_(`gpx.symbol.${symbolKey}`)}
</span>
<Dot size="16" />
{/if}
{waypoint.item.getLatitude().toFixed(6)}&deg; {waypoint.item.getLongitude().toFixed(6)}&deg;
{#if waypoint.item.ele !== undefined}
<Dot size="16" />
<WithUnits value={waypoint.item.ele} type="elevation" />
{/if}
</div>
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
{#if waypoint.item.desc}
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
{/if}
{#if waypoint.item.cmt && waypoint.item.cmt !== waypoint.item.desc}
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.cmt)}</span>
{/if}
</ScrollArea>
{#if $currentTool === Tool.WAYPOINT}
<Button
class="mt-2 w-full px-2 py-1 h-8 justify-start"
variant="outline"
on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
>
<Trash2 size="16" class="mr-1" />
{$_('menu.delete')}
<Shortcut shift={true} click={true} />
</Button>
{/if}
</Card.Content>
</Card.Root>
<style lang="postcss">
div :global(a) {
@apply text-blue-500 dark:text-blue-300;
@apply text-link;
@apply hover:underline;
}
div :global(img) {
@apply my-0;
@apply rounded-md;
}
</style>

View File

@@ -1,25 +0,0 @@
import { dbUtils } from "$lib/db";
import type { Waypoint } from "gpx";
import mapboxgl from "mapbox-gl";
import { writable } from "svelte/store";
export const currentPopupWaypoint = writable<[Waypoint, string] | null>(null);
export const waypointPopup = new mapboxgl.Popup({
closeButton: false,
maxWidth: undefined,
offset: {
'top': [0, 0],
'top-left': [0, 0],
'top-right': [0, 0],
'bottom': [0, -30],
'bottom-left': [0, -30],
'bottom-right': [0, -30],
'left': [0, 0],
'right': [0, 0]
},
});
export function deleteWaypoint(fileId: string, waypointIndex: number) {
dbUtils.applyToFile(fileId, (file) => file.replaceWaypoints(waypointIndex, waypointIndex, []));
}

View File

@@ -19,10 +19,11 @@
} from 'lucide-svelte';
import { _ } from 'svelte-i18n';
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 { onDestroy, onMount } from 'svelte';
import Sortable from 'sortablejs/Sortable';
import { customBasemapUpdate } from './utils';
const {
customLayers,
@@ -94,7 +95,6 @@
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
) {
resourceType = 'vector';
layerType = 'basemap';
} else {
resourceType = 'raster';
}
@@ -108,12 +108,13 @@
if (typeof maxZoom === 'string') {
maxZoom = parseInt(maxZoom);
}
let is512 = tileUrls.some((url) => url.includes('512'));
let layerId = selectedLayerId ?? getLayerId();
let layer: CustomLayer = {
id: layerId,
name: name,
tileUrls: tileUrls,
tileUrls: tileUrls.map((url) => decodeURI(url.trim())),
maxZoom: maxZoom,
layerType: layerType,
resourceType: resourceType,
@@ -121,33 +122,26 @@
};
if (resourceType === 'vector') {
layer.value = tileUrls[0];
layer.value = layer.tileUrls[0];
} else {
if (layerType === 'basemap') {
layer.value = extendBasemap({
version: 8,
sources: {
[layerId]: {
type: 'raster',
tiles: tileUrls,
maxzoom: maxZoom
}
},
layers: [
{
id: layerId,
type: 'raster',
source: layerId
}
]
});
} else {
layer.value = {
type: 'raster',
tiles: tileUrls,
maxzoom: maxZoom
};
}
layer.value = {
version: 8,
sources: {
[layerId]: {
type: 'raster',
tiles: layer.tileUrls,
tileSize: is512 ? 512 : 256,
maxzoom: maxZoom
}
},
layers: [
{
id: layerId,
type: 'raster',
source: layerId
}
]
};
}
$customLayers[layerId] = layer;
addLayer(layerId);
@@ -173,7 +167,11 @@
return $tree;
});
$currentBasemap = layerId;
if ($currentBasemap === layerId) {
$customBasemapUpdate++;
} else {
$currentBasemap = layerId;
}
if (!$customBasemapOrder.includes(layerId)) {
$customBasemapOrder = [...$customBasemapOrder, layerId];
@@ -187,12 +185,16 @@
return $tree;
});
if ($map && $map.getSource(layerId)) {
// Reset source when updating an existing layer
if ($map.getLayer(layerId)) {
$map.removeLayer(layerId);
if (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
$map.removeSource(layerId);
}
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
@@ -249,12 +251,15 @@
}
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
if ($map) {
if ($map.getLayer(layerId)) {
$map.removeLayer(layerId);
}
if ($map.getSource(layerId)) {
$map.removeSource(layerId);
if (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
}
@@ -391,7 +396,7 @@
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="overlay" id="overlay" disabled={resourceType === 'vector'} />
<RadioGroup.Item value="overlay" id="overlay" />
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
</div>
</RadioGroup.Root>

View File

@@ -11,9 +11,8 @@
import { settings } from '$lib/db';
import { map } from '$lib/stores';
import { get, writable } from 'svelte/store';
import { getLayers } from './utils';
import { customBasemapUpdate, getLayers } from './utils';
import { OverpassLayer } from './OverpassLayer';
import OverpassPopup from './OverpassPopup.svelte';
let container: HTMLDivElement;
let overpassLayer: OverpassLayer;
@@ -34,34 +33,84 @@
if ($map) {
let basemap = basemaps.hasOwnProperty($currentBasemap)
? basemaps[$currentBasemap]
: $customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap];
$map.setStyle(basemap, {
diff: false
});
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
$map.removeImport('basemap');
if (typeof basemap === 'string') {
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
} else {
$map.addImport(
{
id: 'basemap',
data: basemap
},
'overlays'
);
}
}
}
$: if ($map && $currentBasemap) {
$: if ($map && ($currentBasemap || $customBasemapUpdate)) {
setStyle();
}
$: if ($map && $currentOverlays) {
// Add or remove overlay layers depending on the current overlays
let overlayLayers = getLayers($currentOverlays);
Object.keys(overlayLayers).forEach((id) => {
if (overlayLayers[id]) {
if (!addOverlayLayer.hasOwnProperty(id)) {
addOverlayLayer[id] = addOverlayLayerForId(id);
function addOverlay(id: string) {
try {
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
if (typeof overlay === 'string') {
$map.addImport({ id, url: overlay });
} else {
if ($opacities.hasOwnProperty(id)) {
overlay = {
...overlay,
layers: overlay.layers.map((layer) => {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = $opacities[id];
}
return layer;
})
};
}
if (!$map.getLayer(id)) {
addOverlayLayer[id]();
$map.on('style.load', addOverlayLayer[id]);
}
} else if ($map.getLayer(id)) {
$map.removeLayer(id);
$map.off('style.load', addOverlayLayer[id]);
$map.addImport({
id,
data: overlay
});
}
});
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
function updateOverlays() {
if ($map && $currentOverlays && $opacities) {
let overlayLayers = getLayers($currentOverlays);
try {
let activeOverlays = $map.getStyle().imports.reduce((acc, i) => {
if (!['basemap', 'overlays', 'glyphs-and-sprite'].includes(i.id)) {
acc[i.id] = i;
}
return acc;
}, {});
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
toRemove.forEach((id) => {
$map.removeImport(id);
});
let toAdd = Object.entries(overlayLayers)
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
.map(([id]) => id);
toAdd.forEach((id) => {
addOverlay(id);
});
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
}
$: if ($map && $currentOverlays && $opacities) {
updateOverlays();
}
$: if ($map) {
@@ -70,6 +119,7 @@
}
overpassLayer = new OverpassLayer($map);
overpassLayer.add();
$map.on('style.import.load', updateOverlays);
}
let selectedBasemap = writable(get(currentBasemap));
@@ -82,40 +132,11 @@
});
currentBasemap.subscribe((value) => {
// Updates coming from the database, or from the user swapping basemaps
selectedBasemap.set(value);
if (value !== get(selectedBasemap)) {
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;
function openLayerControl() {
open = true;
@@ -192,8 +213,6 @@
</div>
</CustomControl>
<OverpassPopup />
<svelte:window
on:click={(e) => {
if (open && !cancelEvents && !container.contains(e.target)) {

View File

@@ -9,8 +9,14 @@
import * as Select from '$lib/components/ui/select';
import { Slider } from '$lib/components/ui/slider';
import { basemapTree, overlays, overlayTree, overpassTree } from '$lib/assets/layers';
import { isSelected } from '$lib/components/layer-control/utils';
import {
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 { _ } from 'svelte-i18n';
@@ -22,6 +28,7 @@
selectedBasemapTree,
selectedOverlayTree,
selectedOverpassTree,
currentBasemap,
currentOverlays,
customLayers,
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) {
setOpacityFromSelection();
}
@@ -126,15 +157,16 @@
max={1}
step={0.1}
disabled={$selectedOverlay === undefined}
onValueChange={() => {
onValueChange={(value) => {
if ($selectedOverlay) {
$opacities[$selectedOverlay.value] = $overlayOpacity[0];
if ($map) {
if ($map.getLayer($selectedOverlay.value)) {
$map.removeLayer($selectedOverlay.value);
$currentOverlays = $currentOverlays;
if ($map && isSelected($currentOverlays, $selectedOverlay.value)) {
try {
$map.removeImport($selectedOverlay.value);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
$opacities[$selectedOverlay.value] = value[0];
}
}}
/>

View File

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

View File

@@ -1,10 +1,10 @@
import SphericalMercator from "@mapbox/sphericalmercator";
import { getLayers } from "./utils";
import mapboxgl from "mapbox-gl";
import { get, writable } from "svelte/store";
import { liveQuery } from "dexie";
import { db, settings } from "$lib/db";
import { overpassQueryData } from "$lib/assets/layers";
import { MapPopup } from "$lib/components/MapPopup";
const {
currentOverpassQueries
@@ -14,14 +14,6 @@ const mercator = new SphericalMercator({
size: 256,
});
export const overpassPopupPOI = writable<Record<string, any> | null>(null);
export const overpassPopup = new mapboxgl.Popup({
closeButton: false,
maxWidth: undefined,
offset: 15,
});
let data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
@@ -34,6 +26,7 @@ export class OverpassLayer {
queryZoom = 12;
expirationTime = 7 * 24 * 3600 * 1000;
map: mapboxgl.Map;
popup: MapPopup;
currentQueries: Set<string> = new Set();
nextQueries: Map<string, { x: number, y: number, queries: string[] }> = new Map();
@@ -42,15 +35,20 @@ export class OverpassLayer {
queryIfNeededBinded = this.queryIfNeeded.bind(this);
updateBinded = this.update.bind(this);
onHoverBinded = this.onHover.bind(this);
maybeHidePopupBinded = this.maybeHidePopup.bind(this);
constructor(map: mapboxgl.Map) {
this.map = map;
this.popup = new MapPopup(map, {
closeButton: false,
focusAfterOpen: false,
maxWidth: undefined,
offset: 15,
});
}
add() {
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(currentOverpassQueries.subscribe(() => {
this.updateBinded();
@@ -108,40 +106,29 @@ export class OverpassLayer {
remove() {
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());
if (this.map.getLayer('overpass')) {
this.map.removeLayer('overpass');
}
try {
if (this.map.getLayer('overpass')) {
this.map.removeLayer('overpass');
}
if (this.map.getSource('overpass')) {
this.map.removeSource('overpass');
if (this.map.getSource('overpass')) {
this.map.removeSource('overpass');
}
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
onHover(e: any) {
overpassPopupPOI.set({
...e.features[0].properties,
sym: overpassQueryData[e.features[0].properties.query].symbol ?? ''
this.popup.setItem({
item: {
...e.features[0].properties,
sym: overpassQueryData[e.features[0].properties.query].symbol ?? ''
}
});
overpassPopup.setLngLat(e.features[0].geometry.coordinates);
overpassPopup.addTo(this.map);
this.map.on('mousemove', this.maybeHidePopupBinded);
}
maybeHidePopup(e: any) {
let poi = get(overpassPopupPOI);
if (poi && this.map.project([poi.lon, poi.lat]).dist(this.map.project(e.lngLat)) > 100) {
this.hideWaypointPopup();
}
}
hideWaypointPopup() {
overpassPopupPOI.set(null);
overpassPopup.remove();
this.map.off('mousemove', this.maybeHidePopupBinded);
}
query(bbox: [number, number, number, number]) {

View File

@@ -1,102 +1,95 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { overpassPopup, overpassPopupPOI } from './OverpassLayer';
import { selection } from '$lib/components/file-list/Selection';
import { PencilLine, MapPin } from 'lucide-svelte';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { dbUtils } from '$lib/db';
import type { PopupItem } from '$lib/components/MapPopup';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
let popupElement: HTMLDivElement;
onMount(() => {
overpassPopup.setDOMContent(popupElement);
popupElement.classList.remove('hidden');
});
export let poi: PopupItem<any>;
let tags = {};
let name = '';
$: if ($overpassPopupPOI) {
tags = JSON.parse($overpassPopupPOI.tags);
$: if (poi) {
tags = JSON.parse(poi.item.tags);
if (tags.name !== undefined && tags.name !== '') {
name = tags.name;
} else {
name = $_(`layers.label.${$overpassPopupPOI.query}`);
name = $_(`layers.label.${poi.item.query}`);
}
}
</script>
<div bind:this={popupElement} class="hidden">
{#if $overpassPopupPOI}
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
<Card.Header class="p-0">
<Card.Title class="text-md">
<div class="flex flex-row gap-3">
<div class="flex flex-col">
{name}
<div class="text-muted-foreground text-sm font-normal">
{$overpassPopupPOI.lat.toFixed(6)}&deg; {$overpassPopupPOI.lon.toFixed(6)}&deg;
</div>
</div>
<Button
class="ml-auto p-1.5 h-8"
variant="outline"
href="https://www.openstreetmap.org/edit?editor=id&node={$overpassPopupPOI.id}"
target="_blank"
>
<PencilLine size="16" />
</Button>
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
<Card.Header class="p-0">
<Card.Title class="text-md">
<div class="flex flex-row gap-3">
<div class="flex flex-col">
{name}
<div class="text-muted-foreground text-sm font-normal">
{poi.item.lat.toFixed(6)}&deg; {poi.item.lon.toFixed(6)}&deg;
</div>
</Card.Title>
</Card.Header>
</div>
<Button
class="ml-auto p-1.5 h-8"
variant="outline"
href="https://www.openstreetmap.org/edit?editor=id&node={poi.item.id}"
target="_blank"
>
<PencilLine size="16" />
</Button>
</div>
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
{#if tags.image || tags['image:0']}
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
<!-- svelte-ignore a11y-missing-attribute -->
<img src={tags.image ?? tags['image:0']} />
</div>
{/if}
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
<div class="grid grid-cols-[auto_auto] gap-x-3">
{#each Object.entries(tags) as [key, value]}
{#if key !== 'name' && !key.includes('image')}
<span class="font-mono">{key}</span>
{#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>
{:else if key === 'phone' || key === 'contact:phone'}
<a href={'tel:' + value} class="text-blue-500 underline">{value}</a>
{:else if key === 'email' || key === 'contact:email'}
<a href={'mailto:' + value} class="text-blue-500 underline">{value}</a>
{:else}
<span>{value}</span>
{/if}
<div class="grid grid-cols-[auto_auto] gap-x-3">
{#each Object.entries(tags) as [key, value]}
{#if key !== 'name' && !key.includes('image')}
<span class="font-mono">{key}</span>
{#if key === 'website' || key.startsWith('website:') || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
<a href={value} target="_blank" class="text-link underline">{value}</a>
{:else if key === 'phone' || key === 'contact:phone'}
<a href={'tel:' + value} class="text-link underline">{value}</a>
{:else if key === 'email' || key === 'contact:email'}
<a href={'mailto:' + value} class="text-link underline">{value}</a>
{:else}
<span>{value}</span>
{/if}
{/each}
</div>
<Button
class="mt-2"
variant="outline"
disabled={$selection.size === 0}
on:click={() => {
let desc = Object.entries(tags)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
dbUtils.addOrUpdateWaypoint({
attributes: {
lat: $overpassPopupPOI.lat,
lon: $overpassPopupPOI.lon
},
name: name,
desc: desc,
cmt: desc,
sym: $overpassPopupPOI.sym
});
}}
>
<MapPin size="16" class="mr-1" />
{$_('toolbar.waypoint.add')}
</Button>
</Card.Content>
</Card.Root>
{/if}
</div>
{/if}
{/each}
</div>
</ScrollArea>
<Button
class="mt-2"
variant="outline"
disabled={$selection.size === 0}
on:click={() => {
let desc = Object.entries(tags)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
dbUtils.addOrUpdateWaypoint({
attributes: {
lat: poi.item.lat,
lon: poi.item.lon
},
name: name,
desc: desc,
cmt: desc,
sym: poi.item.sym
});
}}
>
<MapPin size="16" class="mr-1" />
{$_('toolbar.waypoint.add')}
</Button>
</Card.Content>
</Card.Root>

View File

@@ -1,4 +1,5 @@
import type { LayerTreeType } from "$lib/assets/layers";
import { writable } from "svelte/store";
export function anySelectedLayer(node: LayerTreeType) {
return Object.keys(node).find((id) => {
@@ -36,4 +37,17 @@ export function isSelected(node: LayerTreeType, id: string) {
}
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,16 +1,17 @@
import mapboxgl from "mapbox-gl";
import { Viewer } from 'mapillary-js/dist/mapillary.module';
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from "mapbox-gl";
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
import 'mapillary-js/dist/mapillary.css';
import { resetCursor, setPointerCursor } from "$lib/utils";
import type { Writable } from "svelte/store";
const mapillarySource = {
const mapillarySource: VectorSourceSpecification = {
type: 'vector',
tiles: ['https://tiles.mapillary.com/maps/vtp/mly1_computed_public/2/{z}/{x}/{y}?access_token=MLY|4381405525255083|3204871ec181638c3c31320490f03011'],
minzoom: 6,
maxzoom: 14,
};
const mapillarySequenceLayer = {
const mapillarySequenceLayer: LayerSpecification = {
id: 'mapillary-sequence',
type: 'line',
source: 'mapillary',
@@ -26,7 +27,7 @@ const mapillarySequenceLayer = {
},
};
const mapillaryImageLayer = {
const mapillaryImageLayer: LayerSpecification = {
id: 'mapillary-image',
type: 'circle',
source: 'mapillary',
@@ -40,35 +41,56 @@ const mapillaryImageLayer = {
export class MapillaryLayer {
map: mapboxgl.Map;
popup: mapboxgl.Popup;
marker: mapboxgl.Marker;
viewer: Viewer;
active = false;
popupOpen: Writable<boolean>;
addBinded = this.add.bind(this);
onMouseEnterBinded = this.onMouseEnter.bind(this);
onMouseLeaveBinded = this.onMouseLeave.bind(this);
constructor(map: mapboxgl.Map, container: HTMLElement) {
constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: Writable<boolean>) {
this.map = map;
this.viewer = new Viewer({
accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011',
container,
});
container.classList.remove('hidden');
this.popup = new mapboxgl.Popup({
closeButton: false,
maxWidth: container.style.width,
}).setDOMContent(container);
const element = document.createElement('div');
element.className = 'mapboxgl-user-location mapboxgl-user-location-show-heading';
const dot = document.createElement('div');
dot.className = 'mapboxgl-user-location-dot';
const heading = document.createElement('div');
heading.className = 'mapboxgl-user-location-heading';
element.appendChild(dot);
element.appendChild(heading);
this.marker = new mapboxgl.Marker({
rotationAlignment: 'map',
element
});
this.viewer.on('position', async () => {
if (this.popup.isOpen()) {
if (this.active) {
popupOpen.set(true);
let latLng = await this.viewer.getPosition();
this.popup.setLngLat(latLng);
if (!this.map.getBounds().contains(latLng)) {
this.marker.setLngLat(latLng).addTo(this.map);
if (!this.map.getBounds()?.contains(latLng)) {
this.map.panTo(latLng);
}
}
});
this.viewer.on('bearing', (e: ViewerBearingEvent) => {
if (this.active) {
this.marker.setRotation(e.bearing);
}
});
this.popupOpen = popupOpen;
}
add() {
@@ -101,15 +123,19 @@ export class MapillaryLayer {
this.map.removeSource('mapillary');
}
this.popup.remove();
this.marker.remove();
this.popupOpen.set(false);
}
closePopup() {
this.popup.remove();
this.active = false;
this.marker.remove();
this.popupOpen.set(false);
}
onMouseEnter(e: mapboxgl.MapLayerMouseEvent) {
this.popup.addTo(this.map).setLngLat(e.lngLat);
onMouseEnter(e: mapboxgl.MapMouseEvent) {
this.active = true;
this.viewer.resize();
this.viewer.moveTo(e.features[0].properties.id);

View File

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

View File

@@ -9,7 +9,8 @@
Ungroup,
MapPin,
Filter,
Scissors
Scissors,
MountainSnow
} from 'lucide-svelte';
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 ??
''}"
>
<ToolbarItem tool={Tool.ROUTING}>
<Pencil slot="icon" size="18" class="h-" />
<span slot="tooltip">{$_('toolbar.routing.tooltip')}</span>
<ToolbarItem tool={Tool.ROUTING} label={$_('toolbar.routing.tooltip')}>
<Pencil slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.WAYPOINT}>
<ToolbarItem tool={Tool.WAYPOINT} label={$_('toolbar.waypoint.tooltip')}>
<MapPin slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.waypoint.tooltip')}</span>
</ToolbarItem>
<ToolbarItem tool={Tool.SCISSORS}>
<ToolbarItem tool={Tool.SCISSORS} label={$_('toolbar.scissors.tooltip')}>
<Scissors slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.scissors.tooltip')}</span>
</ToolbarItem>
<ToolbarItem tool={Tool.TIME}>
<ToolbarItem tool={Tool.TIME} label={$_('toolbar.time.tooltip')}>
<CalendarClock slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.time.tooltip')}</span>
</ToolbarItem>
<ToolbarItem tool={Tool.MERGE}>
<ToolbarItem tool={Tool.MERGE} label={$_('toolbar.merge.tooltip')}>
<Group slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.merge.tooltip')}</span>
</ToolbarItem>
<ToolbarItem tool={Tool.EXTRACT}>
<ToolbarItem tool={Tool.EXTRACT} label={$_('toolbar.extract.tooltip')}>
<Ungroup slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.extract.tooltip')}</span>
</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" />
<span slot="tooltip">{$_('toolbar.reduce.tooltip')}</span>
</ToolbarItem>
<ToolbarItem tool={Tool.CLEAN}>
<ToolbarItem tool={Tool.CLEAN} label={$_('toolbar.clean.tooltip')}>
<SquareDashedMousePointer slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.clean.tooltip')}</span>
</ToolbarItem>
</div>
<ToolbarItemMenu class={$$props.class ?? ''} />

View File

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

View File

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

View File

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

View File

@@ -11,13 +11,18 @@
import { selection } from '$lib/components/file-list/Selection';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label/index.js';
import { Checkbox } from '$lib/components/ui/checkbox';
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 { Group } from 'lucide-svelte';
import { getURLForLanguage } from '$lib/utils';
import Shortcut from '$lib/components/Shortcut.svelte';
import { gpxStatistics } from '$lib/stores';
let canMergeTraces = false;
let canMergeContents = false;
let removeGaps = false;
$: if ($selection.size > 1) {
canMergeTraces = true;
@@ -54,35 +59,59 @@
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
<RadioGroup.Root bind:value={mergeType}>
<Label class="flex flex-row items-center gap-2 leading-5">
<Label class="flex flex-row items-center gap-1.5 leading-5">
<RadioGroup.Item value={MergeType.TRACES} />
{$_('toolbar.merge.merge_traces')}
</Label>
<Label class="flex flex-row items-center gap-2 leading-5">
<Label class="flex flex-row items-center gap-1.5 leading-5">
<RadioGroup.Item value={MergeType.CONTENTS} />
{$_('toolbar.merge.merge_contents')}
</Label>
</RadioGroup.Root>
{#if mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0}
<div class="flex flex-row items-center gap-1.5">
<Checkbox id="remove-gaps" bind:checked={removeGaps} />
<Label for="remove-gaps">{$_('toolbar.merge.remove_gaps')}</Label>
</div>
{/if}
<Button
variant="outline"
class="whitespace-normal h-fit"
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
on:click={() => {
dbUtils.mergeSelection(mergeType === MergeType.TRACES);
dbUtils.mergeSelection(
mergeType === MergeType.TRACES,
mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0 && removeGaps
);
}}
>
<Group size="16" class="mr-1" />
<Group size="16" class="mr-1 shrink-0" />
{$_('toolbar.merge.merge_selection')}
</Button>
<Help link="./help/toolbar/merge">
<Help link={getURLForLanguage($locale, '/help/toolbar/merge')}>
{#if mergeType === MergeType.TRACES && canMergeTraces}
{$_('toolbar.merge.help_merge_traces')}
{:else if mergeType === MergeType.TRACES && !canMergeTraces}
{$_('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}
{$_('toolbar.merge.help_merge_contents')}
{:else if mergeType === MergeType.CONTENTS && !canMergeContents}
{$_('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}
</Help>
</div>

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,9 @@
import { Switch } from '$lib/components/ui/switch';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import * as RadioGroup from '$lib/components/ui/radio-group';
import Help from '$lib/components/Help.svelte';
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import Shortcut from '$lib/components/Shortcut.svelte';
import {
@@ -18,16 +20,22 @@
RouteOff,
Repeat,
SquareArrowUpLeft,
SquareArrowOutDownRight
SquareArrowOutDownRight,
Timer
} from 'lucide-svelte';
import { map, newGPXFile, routingControls, selectFileWhenLoaded } from '$lib/stores';
import {
gpxStatistics,
map,
newGPXFile,
routingControls,
selectFileWhenLoaded
} from '$lib/stores';
import { dbUtils, getFile, getFileIds, settings } from '$lib/db';
import { brouterProfiles, routingProfileSelectItem } from './Routing';
import { _ } from 'svelte-i18n';
import { _, locale } from 'svelte-i18n';
import { RoutingControls } from './RoutingControls';
import mapboxgl from 'mapbox-gl';
import { fileObservers } from '$lib/db';
import { slide } from 'svelte/transition';
import { getOrderedSelection, selection } from '$lib/components/file-list/Selection';
@@ -38,7 +46,8 @@
ListTrackSegmentItem,
type ListItem
} from '$lib/components/file-list/FileList';
import { flyAndScale, resetCursor, setCrosshairCursor } from '$lib/utils';
import { flyAndScale, getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
import { TimestampsMode } from '$lib/types';
import { onDestroy, onMount } from 'svelte';
import { TrackPoint } from 'gpx';
@@ -48,7 +57,7 @@
export let popupElement: HTMLElement | undefined = undefined;
let selectedItem: ListItem | null = null;
const { privateRoads, routing } = settings;
const { privateRoads, routing, timestampsMode } = settings;
$: if ($map && popup && popupElement) {
// remove controls for deleted files
@@ -105,7 +114,7 @@
});
</script>
{#if minimized}
{#if minimizable && minimized}
<div class="-m-1.5 -mb-2">
<Button variant="ghost" class="px-1 h-[26px]" on:click={() => (minimized = false)}>
<SquareArrowOutDownRight size="18" />
@@ -116,27 +125,24 @@
class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}"
in:flyAndScale={{ x: -2, y: 0, duration: 50 }}
>
<div class="grow flex flex-col gap-3">
<Tooltip>
<Label slot="data" class="w-full flex flex-row justify-between items-center gap-2">
<span class="flex flex-row gap-1">
{#if $routing}
<Route size="16" />
{:else}
<RouteOff size="16" />
{/if}
{$_('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" />
<div class="flex flex-col gap-3">
<Label class="flex flex-row justify-between items-center gap-2">
<span class="flex flex-row items-center gap-1">
{#if $routing}
<Route size="16" />
{:else}
<RouteOff size="16" />
{/if}
{$_('toolbar.routing.use_routing')}
</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}
<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">
{#if $routingProfileSelectItem.value.includes('bike') || $routingProfileSelectItem.value.includes('motorcycle')}
<Bike size="16" />
@@ -162,81 +168,95 @@
</Select.Content>
</Select.Root>
</Label>
<Label class="w-full flex flex-row justify-between items-center gap-2">
<span class="flex flex-row gap-1"
><TriangleAlert size="16" />{$_('toolbar.routing.allow_private')}</span
>
<Label class="flex flex-row justify-between items-center gap-2">
<span class="flex flex-row items-center gap-1">
<TriangleAlert size="16" />
{$_('toolbar.routing.allow_private')}
</span>
<Switch class="scale-90" bind:checked={$privateRoads} />
</Label>
</div>
{/if}
{#if $gpxStatistics.global.time.total > 0}
<RadioGroup.Root bind:value={$timestampsMode}>
<div class="flex flex-row items-center gap-2">
<RadioGroup.Item
value={TimestampsMode.PRESERVE_AVERAGE_SPEED}
id={TimestampsMode.PRESERVE_AVERAGE_SPEED}
/>
<Label for={TimestampsMode.PRESERVE_AVERAGE_SPEED}>
{$_('toolbar.routing.preserve_average_speed')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<RadioGroup.Item
value={TimestampsMode.PRESERVE_TIMESTAMPS}
id={TimestampsMode.PRESERVE_TIMESTAMPS}
/>
<Label for={TimestampsMode.PRESERVE_TIMESTAMPS}>
{$_('toolbar.routing.preserve_timestamps')}
</Label>
</div>
</RadioGroup.Root>
{/if}
</div>
<div class="flex flex-row flex-wrap justify-center gap-1">
<Tooltip>
<Button
slot="data"
variant="outline"
class="flex flex-row gap-1 text-xs px-2"
disabled={!validSelection}
on:click={dbUtils.reverseSelection}
>
<ArrowRightLeft size="12" />{$_('toolbar.routing.reverse.button')}
</Button>
<span slot="tooltip">{$_('toolbar.routing.reverse.tooltip')}</span>
</Tooltip>
<Tooltip>
<Button
slot="data"
variant="outline"
class="flex flex-row gap-1 text-xs px-2"
disabled={!validSelection}
on:click={() => {
const selected = getOrderedSelection();
if (selected.length > 0) {
const firstFileId = selected[0].getFileId();
const firstFile = getFile(firstFileId);
if (firstFile) {
let start = (() => {
if (selected[0] instanceof ListFileItem) {
return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
} else if (selected[0] instanceof ListTrackItem) {
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[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());
<ButtonWithTooltip
label={$_('toolbar.routing.reverse.tooltip')}
variant="outline"
class="flex flex-row gap-1 text-xs px-2"
disabled={!validSelection}
on:click={dbUtils.reverseSelection}
>
<ArrowRightLeft size="12" />{$_('toolbar.routing.reverse.button')}
</ButtonWithTooltip>
<ButtonWithTooltip
label={$_('toolbar.routing.route_back_to_start.tooltip')}
variant="outline"
class="flex flex-row gap-1 text-xs px-2"
disabled={!validSelection}
on:click={() => {
const selected = getOrderedSelection();
if (selected.length > 0) {
const firstFileId = selected[0].getFileId();
const firstFile = getFile(firstFileId);
if (firstFile) {
let start = (() => {
if (selected[0] instanceof ListFileItem) {
return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
} else if (selected[0] instanceof ListTrackItem) {
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[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());
}
}
}}
>
<Home size="12" />{$_('toolbar.routing.route_back_to_start.button')}
</Button>
<span slot="tooltip">{$_('toolbar.routing.route_back_to_start.tooltip')}</span>
</Tooltip>
<Tooltip>
<Button
slot="data"
variant="outline"
class="flex flex-row gap-1 text-xs px-2"
disabled={!validSelection}
on:click={dbUtils.createRoundTripForSelection}
>
<Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
</Button>
<span slot="tooltip">{$_('toolbar.routing.round_trip.tooltip')}</span>
</Tooltip>
}
}}
>
<Home size="12" />{$_('toolbar.routing.route_back_to_start.button')}
</ButtonWithTooltip>
<ButtonWithTooltip
label={$_('toolbar.routing.round_trip.tooltip')}
variant="outline"
class="flex flex-row gap-1 text-xs px-2"
disabled={!validSelection}
on:click={dbUtils.createRoundTripForSelection}
>
<Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
</ButtonWithTooltip>
</div>
<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}
{$_('toolbar.routing.help_no_file')}
{:else}

View File

@@ -3,7 +3,6 @@ import { TrackPoint, distance } from "gpx";
import { derived, get, writable } from "svelte/store";
import { settings } from "$lib/db";
import { _, isLoading, locale } from "svelte-i18n";
import { map } from "$lib/stores";
import { getElevation } from "$lib/utils";
const { routing, routingProfile, privateRoads } = settings;
@@ -24,7 +23,7 @@ export const routingProfileSelectItem = writable({
});
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) => {
item.value = profile;
item.label = get(_)(`toolbar.routing.activities.${profile}`);
@@ -66,7 +65,7 @@ async function getRoute(points: Coordinates[], brouterProfile: string, privateRo
const latIdx = messages[0].indexOf("Latitude");
const tagIdx = messages[0].indexOf("WayTags");
let messageIdx = 1;
let surface = messageIdx < messages.length ? getSurface(messages[messageIdx][tagIdx]) : "unknown";
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
for (let i = 0; i < coordinates.length; i++) {
let coord = coordinates[i];
@@ -77,28 +76,32 @@ async function getRoute(points: Coordinates[], brouterProfile: string, privateRo
},
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0)
}));
route[route.length - 1].setSurface(surface)
if (messageIdx < messages.length &&
coordinates[i][0] == Number(messages[messageIdx][lngIdx]) / 1000000 &&
coordinates[i][1] == Number(messages[messageIdx][latIdx]) / 1000000) {
messageIdx++;
if (messageIdx == messages.length) surface = "unknown";
else surface = getSurface(messages[messageIdx][tagIdx]);
if (messageIdx == messages.length) tags = {};
else tags = getTags(messages[messageIdx][tagIdx]);
}
route[route.length - 1].setExtensions(tags);
}
return route;
}
function getSurface(message: string): string {
function getTags(message: string): { [key: string]: string } {
const fields = message.split(" ");
for (let i = 0; i < fields.length; i++) if (fields[i].startsWith("surface=")) {
return fields[i].substring(8);
let tags: { [key: string]: string } = {};
for (let i = 0; i < fields.length; i++) {
let [key, value] = fields[i].split("=");
key = key.replace(/:/g, '_');
tags[key] = value;
}
return "unknown";
};
return tags;
}
function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
let route: TrackPoint[] = [];
@@ -125,13 +128,10 @@ function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
}
}));
let m = get(map);
route.forEach((point) => {
point.setSurface("unknown");
if (m) {
point.ele = getElevation(m, point.getCoordinates());
}
return getElevation(route).then((elevations) => {
route.forEach((point, i) => {
point.ele = elevations[i];
});
return route;
});
return new Promise((resolve) => resolve(route));
}

View File

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

View File

@@ -1,16 +1,17 @@
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 mapboxgl from "mapbox-gl";
import { route } from "./Routing";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { dbUtils, type GPXFileWithStatistics } from "$lib/db";
import { dbUtils, settings, type GPXFileWithStatistics } from "$lib/db";
import { getOrderedSelection, selection } from "$lib/components/file-list/Selection";
import { ListFileItem, ListTrackItem, ListTrackSegmentItem } from "$lib/components/file-list/FileList";
import { currentTool, streetViewEnabled, Tool } from "$lib/stores";
import { getClosestLinePoint, resetCursor, setGrabbingCursor } from "$lib/utils";
import { TimestampsMode } from "$lib/types";
const { streetViewSource, timestampsMode } = settings;
export const canChangeStart = writable(false);
@@ -81,7 +82,6 @@ export class RoutingControls {
add() {
this.active = true;
this.map.on('zoom', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.on('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.on('click', this.appendAnchorBinded);
this.map.on('mousemove', this.fileId, this.showTemporaryAnchorBinded);
@@ -129,7 +129,6 @@ export class RoutingControls {
for (let anchor of this.anchors) {
anchor.marker.remove();
}
this.map.off('zoom', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.off('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.off('click', this.appendAnchorBinded);
this.map.off('mousemove', this.fileId, this.showTemporaryAnchorBinded);
@@ -187,11 +186,13 @@ export class RoutingControls {
return (e: any) => {
e.preventDefault();
e.stopPropagation();
if (marker === this.temporaryAnchor.marker) {
if (Date.now() - this.lastDragEvent < 100) { // Prevent click event during drag
return;
}
if (Date.now() - this.lastDragEvent < 100) { // Prevent click event during drag
if (marker === this.temporaryAnchor.marker) {
this.turnIntoPermanentAnchor();
return;
}
@@ -228,14 +229,15 @@ export class RoutingControls {
toggleAnchorsForZoomLevelAndBounds() { // Show markers only if they are in the current zoom level and bounds
this.shownAnchors.splice(0, this.shownAnchors.length);
let southWest = this.map.unproject([0, this.map.getCanvas().height]);
let northEast = this.map.unproject([this.map.getCanvas().width, 0]);
let bounds = new mapboxgl.LngLatBounds(southWest, northEast);
let center = this.map.getCenter();
let bottomLeft = this.map.unproject([0, this.map.getCanvas().height]);
let topRight = this.map.unproject([this.map.getCanvas().width, 0]);
let diagonal = bottomLeft.distanceTo(topRight);
let zoom = this.map.getZoom();
this.anchors.forEach((anchor) => {
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);
this.shownAnchors.push(anchor);
} else {
@@ -335,14 +337,14 @@ export class RoutingControls {
let file = get(this.file)?.file;
// 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;
file?.forEachSegment((segment, trackIndex, segmentIndex) => {
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
let details: any = {};
let closest = getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
if (details.distance < minDistance) {
minDistance = details.distance;
if (details.distance < minDetails.distance) {
minDetails = details;
minAnchor = {
point: closest,
segment,
@@ -353,9 +355,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;
}
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) {
return () => this.deleteAnchor(anchor);
}
@@ -401,7 +459,7 @@ export class RoutingControls {
}
async appendAnchor(e: mapboxgl.MapMouseEvent) { // Add a new anchor to the end of the last segment
if (get(streetViewEnabled)) {
if (get(streetViewEnabled) && get(streetViewSource) === 'google') {
return;
}
@@ -517,9 +575,11 @@ export class RoutingControls {
}
if (anchors[0].point._data.index === 0) { // First anchor is the first point of the segment
response[0].time = anchors[0].point.time;
anchors[0].point = response[0]; // replace the first anchor
anchors[0].point._data.index = 0;
} else if (anchors[0].point._data.index === segment.trkpt.length - 1 && distance(anchors[0].point.getCoordinates(), response[0].getCoordinates()) < 1) { // First anchor is the last point of the segment, and the new point is close enough
response[0].time = anchors[0].point.time;
anchors[0].point = response[0]; // replace the first anchor
anchors[0].point._data.index = segment.trkpt.length - 1;
} else {
@@ -528,6 +588,7 @@ export class RoutingControls {
}
if (anchors[anchors.length - 1].point._data.index === segment.trkpt.length - 1) { // Last anchor is the last point of the segment
response[response.length - 1].time = anchors[anchors.length - 1].point.time;
anchors[anchors.length - 1].point = response[response.length - 1]; // replace the last anchor
anchors[anchors.length - 1].point._data.index = segment.trkpt.length - 1;
} else {
@@ -551,20 +612,40 @@ export class RoutingControls {
let startTime = anchors[0].point.time;
if (stats.global.speed.moving > 0) {
let replacingTime = 0;
if (get(timestampsMode) === TimestampsMode.PRESERVE_TIMESTAMPS) {
this.extendResponseToContiguousAdaptedTimePoints(segment, anchors, response);
response.forEach((point) => point.time = undefined);
startTime = anchors[0].point.time;
replacingTime = stats.local.time.total[anchors[anchors.length - 1].point._data.index] - stats.local.time.total[anchors[0].point._data.index];
if (replacingTime > 0) {
for (let i = 1; i < anchors.length - 1; i++) {
anchors[i].point._data['adapted_time'] = true;
}
}
}
let replacingDistance = 0;
for (let i = 1; i < response.length; i++) {
replacingDistance += distance(response[i - 1].getCoordinates(), response[i].getCoordinates()) / 1000;
}
let replacedDistance = stats.local.distance.moving[anchors[anchors.length - 1].point._data.index] - stats.local.distance.moving[anchors[0].point._data.index];
let newDistance = stats.global.distance.moving + replacingDistance - replacedDistance;
let newTime = newDistance / stats.global.speed.moving * 3600;
if (get(timestampsMode) === TimestampsMode.PRESERVE_AVERAGE_SPEED || replacingTime === 0) {
let replacedDistance = stats.local.distance.moving[anchors[anchors.length - 1].point._data.index] - stats.local.distance.moving[anchors[0].point._data.index];
let remainingTime = stats.global.time.moving - (stats.local.time.moving[anchors[anchors.length - 1].point._data.index] - stats.local.time.moving[anchors[0].point._data.index]);
let replacingTime = newTime - remainingTime;
let newDistance = stats.global.distance.moving + replacingDistance - replacedDistance;
let newTime = newDistance / stats.global.speed.moving * 3600;
if (replacingTime <= 0) { // Fallback to simple time difference
replacingTime = stats.local.time.total[anchors[anchors.length - 1].point._data.index] - stats.local.time.total[anchors[0].point._data.index];
let remainingTime = stats.global.time.moving - (stats.local.time.moving[anchors[anchors.length - 1].point._data.index] - stats.local.time.moving[anchors[0].point._data.index]);
replacingTime = newTime - remainingTime;
if (replacingTime <= 0) { // Fallback to simple time difference
replacingTime = stats.local.time.total[anchors[anchors.length - 1].point._data.index] - stats.local.time.total[anchors[0].point._data.index];
}
}
speed = replacingDistance / replacingTime * 3600;
@@ -580,6 +661,41 @@ export class RoutingControls {
return true;
}
extendResponseToContiguousAdaptedTimePoints(segment: TrackSegment, anchors: Anchor[], response: TrackPoint[]) {
while (anchors[0].point._data.adapted_time) {
let previousAnchor = null;
for (let i = 0; i < this.anchors.length; i++) {
if (this.anchors[i].point._data.index < anchors[0].point._data.index) {
previousAnchor = this.anchors[i];
} else {
break;
}
}
if (previousAnchor === null) {
break;
} else {
response.splice(0, 0, ...segment.trkpt.slice(previousAnchor.point._data.index, anchors[0].point._data.index).map((point) => point.clone()));
anchors.splice(0, 0, previousAnchor);
}
}
while (anchors[anchors.length - 1].point._data.adapted_time) {
let nextAnchor = null;
for (let i = this.anchors.length - 1; i >= 0; i--) {
if (this.anchors[i].point._data.index > anchors[anchors.length - 1].point._data.index) {
nextAnchor = this.anchors[i];
} else {
break;
}
}
if (nextAnchor === null) {
break;
} else {
response.push(...segment.trkpt.slice(anchors[anchors.length - 1].point._data.index + 1, nextAnchor.point._data.index + 1).map((point) => point.clone()));
anchors.push(nextAnchor);
}
}
}
destroy() {
this.remove();
this.unsubscribes.forEach((unsubscribe) => unsubscribe());

View File

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

View File

@@ -49,9 +49,7 @@ export class SplitControls {
}
updateControls() { // Update the markers when the files change
let controlIndex = 0;
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
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?)
if (point._data.anchor) {
if (controlIndex < this.controls.length) {
this.controls[controlIndex].fileId = fileId;
this.controls[controlIndex].point = point;
this.controls[controlIndex].segment = segment;
this.controls[controlIndex].trackIndex = trackIndex;
@@ -117,7 +116,7 @@ export class SplitControls {
createControl(point: TrackPoint, segment: TrackSegment, fileId: string, trackIndex: number, segmentIndex: number): ControlWithMarker {
let element = document.createElement('div');
element.className = `h-6 w-6 p-0.5 rounded-full bg-white border-2 border-black cursor-pointer`;
element.innerHTML = Scissors.replace('width="24"', "").replace('height="24"', "");
element.innerHTML = Scissors.replace('width="24"', "").replace('height="24"', "").replace('stroke="currentColor"', 'stroke="black"');
let marker = new mapboxgl.Marker({
draggable: true,
@@ -137,7 +136,7 @@ export class SplitControls {
marker.getElement().addEventListener('click', (e) => {
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;

View File

@@ -1,31 +1,33 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
import { Scrollbar } from "./index.js";
import { cn } from "$lib/utils.js";
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
import { Scrollbar } from './index.js';
import { cn } from '$lib/utils.js';
type $$Props = ScrollAreaPrimitive.Props & {
orientation?: "vertical" | "horizontal" | "both";
orientation?: 'vertical' | 'horizontal' | 'both';
scrollbarXClasses?: string;
scrollbarYClasses?: string;
viewportClasses?: string;
};
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
export let orientation = "vertical";
export let scrollbarXClasses: string = "";
export let scrollbarYClasses: string = "";
export let orientation = 'vertical';
export let scrollbarXClasses: string = '';
export let scrollbarYClasses: string = '';
export let viewportClasses: string = '';
</script>
<ScrollAreaPrimitive.Root {...$$restProps} class={cn("relative overflow-hidden", className)}>
<ScrollAreaPrimitive.Viewport class="h-full w-full rounded-[inherit]">
<ScrollAreaPrimitive.Root {...$$restProps} class={cn('relative overflow-hidden', className)}>
<ScrollAreaPrimitive.Viewport class={cn('h-full w-full rounded-[inherit]', viewportClasses)}>
<ScrollAreaPrimitive.Content>
<slot />
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
{#if orientation === "vertical" || orientation === "both"}
{#if orientation === 'vertical' || orientation === 'both'}
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
{/if}
{#if orientation === "horizontal" || orientation === "both"}
{#if orientation === 'horizontal' || orientation === 'both'}
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
{/if}
<ScrollAreaPrimitive.Corner />

View File

@@ -9,9 +9,9 @@ import { ListFileItem, ListItem, ListTrackItem, ListLevel, ListTrackSegmentItem,
import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simplify';
import { SplitType } from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import { getClosestLinePoint, getElevation } from '$lib/utils';
import { TimestampsMode } from '$lib/types';
import { browser } from '$app/environment';
enableMapSet();
enablePatches();
@@ -80,7 +80,7 @@ export function dexieSettingStore<T>(key: string, initial: T, initialize: boolea
}
export const settings = {
distanceUnits: dexieSettingStore<'metric' | 'imperial'>('distanceUnits', 'metric'),
distanceUnits: dexieSettingStore<'metric' | 'imperial' | 'nautical'>('distanceUnits', 'metric'),
velocityUnits: dexieSettingStore<'speed' | 'pace'>('velocityUnits', 'speed'),
temperatureUnits: dexieSettingStore<'celsius' | 'fahrenheit'>('temperatureUnits', 'celsius'),
elevationProfile: dexieSettingStore('elevationProfile', true),
@@ -91,6 +91,7 @@ export const settings = {
routing: dexieSettingStore('routing', true),
routingProfile: dexieSettingStore('routingProfile', 'bike'),
privateRoads: dexieSettingStore('privateRoads', false),
timestampsMode: dexieSettingStore('timestampsMode', TimestampsMode.PRESERVE_AVERAGE_SPEED),
currentBasemap: dexieSettingStore('currentBasemap', defaultBasemap),
previousBasemap: dexieSettingStore('previousBasemap', defaultBasemap),
selectedBasemapTree: dexieSettingStore('selectedBasemapTree', defaultBasemapTree),
@@ -111,7 +112,6 @@ export const settings = {
defaultWeight: dexieSettingStore('defaultWeight', (browser && window.innerWidth < 600) ? 8 : 5),
bottomPanelSize: dexieSettingStore('bottomPanelSize', 170),
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
@@ -181,7 +181,7 @@ function dexieGPXFileStore(id: string): Readable<GPXFileWithStatistics> & { dest
let statistics = new GPXStatisticsTree(gpx);
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);
@@ -288,12 +288,12 @@ export const fileObservers: Writable<Map<string, Readable<GPXFileWithStatistics
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
export function observeFilesFromDatabase() {
export function observeFilesFromDatabase(fitBounds: boolean) {
let initialize = true;
liveQuery(() => db.fileids.toArray()).subscribe(dbFileIds => {
if (initialize) {
if (dbFileIds.length > 0) {
initTargetMapBounds(dbFileIds.length);
if (fitBounds && dbFileIds.length > 0) {
initTargetMapBounds(dbFileIds);
}
initialize = false;
}
@@ -454,13 +454,14 @@ export const dbUtils = {
});
},
addMultiple: (files: GPXFile[]) => {
return applyGlobal((draft) => {
let ids = getFileIds(files.length);
let ids = getFileIds(files.length);
applyGlobal((draft) => {
files.forEach((file, index) => {
file._data.id = ids[index];
draft.set(file._data.id, freeze(file));
});
});
return ids;
},
applyToFile: (id: string, callback: (file: WritableDraft<GPXFile>) => void) => {
applyToFiles([id], callback);
@@ -513,8 +514,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: () => {
if (!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) {
if (!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) || get(gpxStatistics).local.points?.length <= 1) {
return;
}
applyGlobal((draft) => {
@@ -565,7 +575,7 @@ export const dbUtils = {
});
});
},
mergeSelection: (mergeTraces: boolean) => {
mergeSelection: (mergeTraces: boolean, removeGaps: boolean) => {
applyGlobal((draft) => {
let first = true;
let target: ListItem = new ListRootItem();
@@ -640,7 +650,7 @@ export const dbUtils = {
let s = new TrackSegment();
toMerge.trk.map((track) => {
track.trkseg.forEach((segment) => {
s.replaceTrackPoints(s.trkpt.length, s.trkpt.length, segment.trkpt.slice(), speed, startTime);
s.replaceTrackPoints(s.trkpt.length, s.trkpt.length, segment.trkpt.slice(), speed, startTime, removeGaps);
});
});
toMerge.trk = [toMerge.trk[0]];
@@ -649,7 +659,7 @@ export const dbUtils = {
if (toMerge.trkseg.length > 0) {
let s = new TrackSegment();
toMerge.trkseg.forEach((segment) => {
s.replaceTrackPoints(s.trkpt.length, s.trkpt.length, segment.trkpt.slice(), speed, startTime);
s.replaceTrackPoints(s.trkpt.length, s.trkpt.length, segment.trkpt.slice(), speed, startTime, removeGaps);
});
toMerge.trkseg = [s];
}
@@ -912,29 +922,30 @@ export const dbUtils = {
if (m === null) {
return;
}
let ele = getElevation(m, waypoint.attributes);
if (item) {
dbUtils.applyToFile(item.getFileId(), (file) => {
let wpt = file.wpt[item.getWaypointIndex()];
wpt.name = waypoint.name;
wpt.desc = waypoint.desc;
wpt.cmt = waypoint.cmt;
wpt.sym = waypoint.sym;
wpt.link = waypoint.link;
wpt.setCoordinates(waypoint.attributes);
wpt.ele = ele;
});
} else {
let fileIds = new Set<string>();
get(selection).getSelected().forEach((item) => {
fileIds.add(item.getFileId());
});
let wpt = new Waypoint(waypoint);
wpt.ele = ele;
dbUtils.applyToFiles(Array.from(fileIds), (file) =>
file.replaceWaypoints(file.wpt.length, file.wpt.length, [wpt])
);
}
getElevation([waypoint.attributes]).then((elevation) => {
if (item) {
dbUtils.applyToFile(item.getFileId(), (file) => {
let wpt = file.wpt[item.getWaypointIndex()];
wpt.name = waypoint.name;
wpt.desc = waypoint.desc;
wpt.cmt = waypoint.cmt;
wpt.sym = waypoint.sym;
wpt.link = waypoint.link;
wpt.setCoordinates(waypoint.attributes);
wpt.ele = elevation[0];
});
} else {
let fileIds = new Set<string>();
get(selection).getSelected().forEach((item) => {
fileIds.add(item.getFileId());
});
let wpt = new Waypoint(waypoint);
wpt.ele = elevation[0];
dbUtils.applyToFiles(Array.from(fileIds), (file) =>
file.replaceWaypoints(file.wpt.length, file.wpt.length, [wpt])
);
}
});
},
setStyleToSelection: (style: LineStyleExtension) => {
if (get(selection).size === 0) {
@@ -1022,6 +1033,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: () => {
if (get(selection).size === 0) {
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 { ChartNoAxesColumn } 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 <kbd><ChartNoAxesColumn size="16" class="inline-block" style="margin-bottom: 2px"/></kbd> button at the bottom-right of the elevation profile, you can optionally color the elevation profile by:
- **slope** information computed from the elevation data, or
- **surface** or **category** 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> and <a href="https://wiki.openstreetmap.org/wiki/Key:highway" target="_blank">highway</a> tags.
This is only available for files created with **gpx.studio**.
If your selection includes it, you can also visualize: **speed**, **heart rate**, **cadence**, **temperature** and **power** 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).
Наступныя секцыі апісваюць кожны інструмент больш дэтальна.

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