1587 Commits

Author SHA1 Message Date
vcoppe
cfb629dcfb New translations elevation.mdx (Serbian (Latin)) 2025-11-12 18:00:29 +01:00
vcoppe
268afe5f84 New translations elevation.mdx (Chinese Traditional, Hong Kong) 2025-11-12 18:00:27 +01:00
vcoppe
6832b7b1ca New translations elevation.mdx (Latvian) 2025-11-12 18:00:26 +01:00
vcoppe
042c900f80 New translations elevation.mdx (Thai) 2025-11-12 18:00:24 +01:00
vcoppe
6524864dea New translations elevation.mdx (Indonesian) 2025-11-12 18:00:23 +01:00
vcoppe
3ad29a2f8b New translations elevation.mdx (Portuguese, Brazilian) 2025-11-12 18:00:22 +01:00
vcoppe
f40c01109a New translations elevation.mdx (Vietnamese) 2025-11-12 18:00:20 +01:00
vcoppe
2c875bd96b New translations elevation.mdx (Chinese Simplified) 2025-11-12 18:00:18 +01:00
vcoppe
ace184c5f4 New translations elevation.mdx (Ukrainian) 2025-11-12 18:00:17 +01:00
vcoppe
3857649baa New translations elevation.mdx (Turkish) 2025-11-12 18:00:16 +01:00
vcoppe
db32681bcf New translations elevation.mdx (Swedish) 2025-11-12 18:00:14 +01:00
vcoppe
0c551a5991 New translations elevation.mdx (Russian) 2025-11-12 18:00:13 +01:00
vcoppe
be39bc80a0 New translations elevation.mdx (Portuguese) 2025-11-12 18:00:11 +01:00
vcoppe
1798cbcc1f New translations elevation.mdx (Polish) 2025-11-12 18:00:09 +01:00
vcoppe
054abd8555 New translations elevation.mdx (Norwegian) 2025-11-12 18:00:08 +01:00
vcoppe
febf949e83 New translations elevation.mdx (Dutch) 2025-11-12 18:00:06 +01:00
vcoppe
712ca963d2 New translations elevation.mdx (Lithuanian) 2025-11-12 18:00:05 +01:00
vcoppe
41c06e9c98 New translations elevation.mdx (Korean) 2025-11-12 18:00:03 +01:00
vcoppe
7bf411672d New translations elevation.mdx (Italian) 2025-11-12 18:00:02 +01:00
vcoppe
3d7f98903f New translations elevation.mdx (Hungarian) 2025-11-12 18:00:00 +01:00
vcoppe
ea744f4979 New translations elevation.mdx (Hebrew) 2025-11-12 17:59:59 +01:00
vcoppe
3ffec22e5e New translations elevation.mdx (Finnish) 2025-11-12 17:59:58 +01:00
vcoppe
243b6cbe79 New translations elevation.mdx (Basque) 2025-11-12 17:59:57 +01:00
vcoppe
79872ac8e4 New translations elevation.mdx (Greek) 2025-11-12 17:59:56 +01:00
vcoppe
0214cad3dd New translations elevation.mdx (German) 2025-11-12 17:59:54 +01:00
vcoppe
818b3e0d44 New translations elevation.mdx (Danish) 2025-11-12 17:59:53 +01:00
vcoppe
b61313ce6c New translations elevation.mdx (Czech) 2025-11-12 17:59:52 +01:00
vcoppe
f511da811b New translations elevation.mdx (Catalan) 2025-11-12 17:59:51 +01:00
vcoppe
b7b1662383 New translations elevation.mdx (Belarusian) 2025-11-12 17:59:50 +01:00
vcoppe
716a28352d New translations elevation.mdx (Spanish) 2025-11-12 17:59:48 +01:00
vcoppe
bf05c28e32 New translations elevation.mdx (French) 2025-11-12 17:59:47 +01:00
vcoppe
0c8f516e39 New translations elevation.mdx (Romanian) 2025-11-12 17:59:46 +01:00
vcoppe
ad7355e976 New translations time.mdx (Serbian (Latin)) 2025-11-12 17:59:31 +01:00
vcoppe
9734bb251b New translations time.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:59:30 +01:00
vcoppe
b1986f68d6 New translations time.mdx (Latvian) 2025-11-12 17:59:29 +01:00
vcoppe
957faced0a New translations time.mdx (Thai) 2025-11-12 17:59:28 +01:00
vcoppe
4e43867ab3 New translations time.mdx (Indonesian) 2025-11-12 17:59:26 +01:00
vcoppe
0b53016675 New translations time.mdx (Portuguese, Brazilian) 2025-11-12 17:59:25 +01:00
vcoppe
04c2411432 New translations time.mdx (Vietnamese) 2025-11-12 17:59:24 +01:00
vcoppe
237e673963 New translations time.mdx (Chinese Simplified) 2025-11-12 17:59:22 +01:00
vcoppe
d48b7c1927 New translations time.mdx (Ukrainian) 2025-11-12 17:59:21 +01:00
vcoppe
39602fa0e9 New translations time.mdx (Turkish) 2025-11-12 17:59:20 +01:00
vcoppe
08704e2eff New translations time.mdx (Swedish) 2025-11-12 17:59:19 +01:00
vcoppe
79178a6514 New translations time.mdx (Russian) 2025-11-12 17:59:17 +01:00
vcoppe
05cfeca5b4 New translations time.mdx (Portuguese) 2025-11-12 17:59:16 +01:00
vcoppe
5b93749051 New translations time.mdx (Polish) 2025-11-12 17:59:15 +01:00
vcoppe
355ef01a38 New translations time.mdx (Norwegian) 2025-11-12 17:59:13 +01:00
vcoppe
c382298c7e New translations time.mdx (Dutch) 2025-11-12 17:59:12 +01:00
vcoppe
b083c6e0a2 New translations time.mdx (Lithuanian) 2025-11-12 17:59:11 +01:00
vcoppe
96275b9e8e New translations time.mdx (Korean) 2025-11-12 17:59:10 +01:00
vcoppe
91ae587254 New translations time.mdx (Italian) 2025-11-12 17:59:09 +01:00
vcoppe
18a8c02e7c New translations time.mdx (Hungarian) 2025-11-12 17:59:07 +01:00
vcoppe
daa2d662de New translations time.mdx (Hebrew) 2025-11-12 17:59:06 +01:00
vcoppe
c0d310fc1d New translations time.mdx (Finnish) 2025-11-12 17:59:05 +01:00
vcoppe
78c4553171 New translations time.mdx (Basque) 2025-11-12 17:59:03 +01:00
vcoppe
3906fe45e0 New translations time.mdx (Greek) 2025-11-12 17:59:02 +01:00
vcoppe
b449397914 New translations time.mdx (German) 2025-11-12 17:59:01 +01:00
vcoppe
c0fa6e34c5 New translations time.mdx (Danish) 2025-11-12 17:59:00 +01:00
vcoppe
c5bb302a15 New translations time.mdx (Czech) 2025-11-12 17:58:58 +01:00
vcoppe
05012179f9 New translations time.mdx (Catalan) 2025-11-12 17:58:57 +01:00
vcoppe
a53c934a1e New translations time.mdx (Belarusian) 2025-11-12 17:58:55 +01:00
vcoppe
da73f9426a New translations time.mdx (Spanish) 2025-11-12 17:58:54 +01:00
vcoppe
b5383122bc New translations time.mdx (French) 2025-11-12 17:58:53 +01:00
vcoppe
0a059c345b New translations time.mdx (Romanian) 2025-11-12 17:58:52 +01:00
vcoppe
07c979df5d New translations scissors.mdx (Serbian (Latin)) 2025-11-12 17:58:50 +01:00
vcoppe
52a5654923 New translations scissors.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:58:49 +01:00
vcoppe
3a86303865 New translations scissors.mdx (Latvian) 2025-11-12 17:58:48 +01:00
vcoppe
7cc06efda1 New translations scissors.mdx (Thai) 2025-11-12 17:58:47 +01:00
vcoppe
b467b69478 New translations scissors.mdx (Indonesian) 2025-11-12 17:58:45 +01:00
vcoppe
c289c316d7 New translations scissors.mdx (Portuguese, Brazilian) 2025-11-12 17:58:44 +01:00
vcoppe
a30c9ae81a New translations scissors.mdx (Vietnamese) 2025-11-12 17:58:43 +01:00
vcoppe
cb9e316a8c New translations scissors.mdx (Chinese Simplified) 2025-11-12 17:58:42 +01:00
vcoppe
f6abbab561 New translations scissors.mdx (Ukrainian) 2025-11-12 17:58:40 +01:00
vcoppe
aae0626a22 New translations scissors.mdx (Turkish) 2025-11-12 17:58:39 +01:00
vcoppe
b39f30fec6 New translations scissors.mdx (Swedish) 2025-11-12 17:58:38 +01:00
vcoppe
f323bc2e25 New translations scissors.mdx (Russian) 2025-11-12 17:58:37 +01:00
vcoppe
67abd55120 New translations scissors.mdx (Portuguese) 2025-11-12 17:58:35 +01:00
vcoppe
5f52964672 New translations scissors.mdx (Polish) 2025-11-12 17:58:34 +01:00
vcoppe
084587fcc1 New translations scissors.mdx (Norwegian) 2025-11-12 17:58:33 +01:00
vcoppe
619f129f0c New translations scissors.mdx (Dutch) 2025-11-12 17:58:32 +01:00
vcoppe
acd2962427 New translations scissors.mdx (Lithuanian) 2025-11-12 17:58:31 +01:00
vcoppe
c9ddb5f3d1 New translations scissors.mdx (Korean) 2025-11-12 17:58:29 +01:00
vcoppe
480056ce86 New translations scissors.mdx (Italian) 2025-11-12 17:58:28 +01:00
vcoppe
cc47635a60 New translations scissors.mdx (Hungarian) 2025-11-12 17:58:27 +01:00
vcoppe
631b0cd3d3 New translations scissors.mdx (Hebrew) 2025-11-12 17:58:26 +01:00
vcoppe
447d416bbb New translations scissors.mdx (Finnish) 2025-11-12 17:58:25 +01:00
vcoppe
05b8dff0a6 New translations scissors.mdx (Basque) 2025-11-12 17:58:24 +01:00
vcoppe
c44e9be42d New translations scissors.mdx (Greek) 2025-11-12 17:58:22 +01:00
vcoppe
366f26ff0b New translations scissors.mdx (German) 2025-11-12 17:58:21 +01:00
vcoppe
dd1865a2cf New translations scissors.mdx (Danish) 2025-11-12 17:58:20 +01:00
vcoppe
359ce2fedc New translations scissors.mdx (Czech) 2025-11-12 17:58:19 +01:00
vcoppe
dc94a33e64 New translations scissors.mdx (Catalan) 2025-11-12 17:58:17 +01:00
vcoppe
8be176b57e New translations scissors.mdx (Belarusian) 2025-11-12 17:58:16 +01:00
vcoppe
984e518163 New translations scissors.mdx (Spanish) 2025-11-12 17:58:15 +01:00
vcoppe
219afd5f1c New translations scissors.mdx (French) 2025-11-12 17:58:14 +01:00
vcoppe
4d0e0c8c95 New translations scissors.mdx (Romanian) 2025-11-12 17:58:13 +01:00
vcoppe
1bfc19cc74 New translations routing.mdx (Serbian (Latin)) 2025-11-12 17:58:11 +01:00
vcoppe
48f0466e5e New translations routing.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:58:10 +01:00
vcoppe
6c004740c9 New translations routing.mdx (Latvian) 2025-11-12 17:58:09 +01:00
vcoppe
4582ddd1db New translations routing.mdx (Thai) 2025-11-12 17:58:08 +01:00
vcoppe
ea254f89a5 New translations routing.mdx (Indonesian) 2025-11-12 17:58:07 +01:00
vcoppe
6d5880ee09 New translations routing.mdx (Portuguese, Brazilian) 2025-11-12 17:58:06 +01:00
vcoppe
f230c5fd84 New translations routing.mdx (Vietnamese) 2025-11-12 17:58:05 +01:00
vcoppe
2326fb3809 New translations routing.mdx (Chinese Simplified) 2025-11-12 17:58:03 +01:00
vcoppe
270df17c67 New translations routing.mdx (Ukrainian) 2025-11-12 17:58:02 +01:00
vcoppe
cbb386e799 New translations routing.mdx (Turkish) 2025-11-12 17:58:01 +01:00
vcoppe
6c7f4933eb New translations routing.mdx (Swedish) 2025-11-12 17:58:00 +01:00
vcoppe
c572570517 New translations routing.mdx (Russian) 2025-11-12 17:57:58 +01:00
vcoppe
72264b76aa New translations routing.mdx (Portuguese) 2025-11-12 17:57:57 +01:00
vcoppe
3cfe444f8d New translations routing.mdx (Polish) 2025-11-12 17:57:56 +01:00
vcoppe
d9b38bab4c New translations routing.mdx (Norwegian) 2025-11-12 17:57:55 +01:00
vcoppe
b39e09bd0c New translations routing.mdx (Dutch) 2025-11-12 17:57:54 +01:00
vcoppe
cd32631a71 New translations routing.mdx (Lithuanian) 2025-11-12 17:57:52 +01:00
vcoppe
ec8e1455a1 New translations routing.mdx (Korean) 2025-11-12 17:57:51 +01:00
vcoppe
cfd9b9946b New translations routing.mdx (Italian) 2025-11-12 17:57:50 +01:00
vcoppe
961daf76f0 New translations routing.mdx (Hungarian) 2025-11-12 17:57:49 +01:00
vcoppe
256ca75851 New translations routing.mdx (Hebrew) 2025-11-12 17:57:47 +01:00
vcoppe
6fa63cca0d New translations routing.mdx (Finnish) 2025-11-12 17:57:46 +01:00
vcoppe
a8730722dd New translations routing.mdx (Basque) 2025-11-12 17:57:45 +01:00
vcoppe
ededaebd39 New translations routing.mdx (Greek) 2025-11-12 17:57:44 +01:00
vcoppe
80d43a85cd New translations routing.mdx (German) 2025-11-12 17:57:42 +01:00
vcoppe
6b2a946ed7 New translations routing.mdx (Danish) 2025-11-12 17:57:41 +01:00
vcoppe
6822e3bed3 New translations routing.mdx (Catalan) 2025-11-12 17:57:40 +01:00
vcoppe
5c9ae9cde6 New translations routing.mdx (Belarusian) 2025-11-12 17:57:39 +01:00
vcoppe
335505bb6e New translations routing.mdx (Spanish) 2025-11-12 17:57:38 +01:00
vcoppe
a153b3379f New translations routing.mdx (French) 2025-11-12 17:57:37 +01:00
vcoppe
04c280560e New translations routing.mdx (Romanian) 2025-11-12 17:57:35 +01:00
vcoppe
31385fb34f New translations poi.mdx (Serbian (Latin)) 2025-11-12 17:57:34 +01:00
vcoppe
3844360614 New translations poi.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:57:33 +01:00
vcoppe
e9f4487035 New translations poi.mdx (Latvian) 2025-11-12 17:57:32 +01:00
vcoppe
b5c50fbbdf New translations poi.mdx (Thai) 2025-11-12 17:57:31 +01:00
vcoppe
ace8928c99 New translations poi.mdx (Indonesian) 2025-11-12 17:57:29 +01:00
vcoppe
de8c3dbcce New translations poi.mdx (Portuguese, Brazilian) 2025-11-12 17:57:28 +01:00
vcoppe
b6df3ad67f New translations poi.mdx (Vietnamese) 2025-11-12 17:57:26 +01:00
vcoppe
edb9950e38 New translations poi.mdx (Chinese Simplified) 2025-11-12 17:57:25 +01:00
vcoppe
2dc06ddbd4 New translations poi.mdx (Ukrainian) 2025-11-12 17:57:24 +01:00
vcoppe
2cca65730f New translations poi.mdx (Turkish) 2025-11-12 17:57:22 +01:00
vcoppe
ff6090f859 New translations poi.mdx (Swedish) 2025-11-12 17:57:21 +01:00
vcoppe
105a705302 New translations poi.mdx (Russian) 2025-11-12 17:57:20 +01:00
vcoppe
2620dcd2f8 New translations poi.mdx (Portuguese) 2025-11-12 17:57:19 +01:00
vcoppe
74e0f260ae New translations poi.mdx (Polish) 2025-11-12 17:57:18 +01:00
vcoppe
64beb4cc52 New translations poi.mdx (Norwegian) 2025-11-12 17:57:16 +01:00
vcoppe
b531fc5df4 New translations poi.mdx (Dutch) 2025-11-12 17:57:15 +01:00
vcoppe
300ea4b77f New translations poi.mdx (Lithuanian) 2025-11-12 17:57:14 +01:00
vcoppe
309d60fb46 New translations poi.mdx (Korean) 2025-11-12 17:57:13 +01:00
vcoppe
f8606a7453 New translations poi.mdx (Italian) 2025-11-12 17:57:11 +01:00
vcoppe
d7fc332d91 New translations poi.mdx (Hungarian) 2025-11-12 17:57:10 +01:00
vcoppe
bc14935be3 New translations poi.mdx (Hebrew) 2025-11-12 17:57:09 +01:00
vcoppe
6b38430822 New translations poi.mdx (Finnish) 2025-11-12 17:57:08 +01:00
vcoppe
4fb2cd1805 New translations poi.mdx (Basque) 2025-11-12 17:57:07 +01:00
vcoppe
e265d6b561 New translations poi.mdx (Greek) 2025-11-12 17:57:05 +01:00
vcoppe
b6cb891858 New translations poi.mdx (German) 2025-11-12 17:57:04 +01:00
vcoppe
09a9c2ef17 New translations poi.mdx (Danish) 2025-11-12 17:57:03 +01:00
vcoppe
3cb9498c9b New translations poi.mdx (Czech) 2025-11-12 17:57:02 +01:00
vcoppe
1991d88c03 New translations poi.mdx (Catalan) 2025-11-12 17:57:00 +01:00
vcoppe
9e5fa15630 New translations poi.mdx (Belarusian) 2025-11-12 17:56:59 +01:00
vcoppe
d3be51fdcd New translations poi.mdx (Spanish) 2025-11-12 17:56:58 +01:00
vcoppe
8ae62558be New translations poi.mdx (French) 2025-11-12 17:56:57 +01:00
vcoppe
cf0d82a4d2 New translations poi.mdx (Romanian) 2025-11-12 17:56:56 +01:00
vcoppe
b6aa4738cf New translations minify.mdx (Serbian (Latin)) 2025-11-12 17:56:54 +01:00
vcoppe
f73de02f87 New translations minify.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:56:53 +01:00
vcoppe
383bf649c3 New translations minify.mdx (Latvian) 2025-11-12 17:56:52 +01:00
vcoppe
7fc4826f43 New translations minify.mdx (Thai) 2025-11-12 17:56:51 +01:00
vcoppe
4b8fd2f25c New translations minify.mdx (Indonesian) 2025-11-12 17:56:50 +01:00
vcoppe
9dee07a534 New translations minify.mdx (Portuguese, Brazilian) 2025-11-12 17:56:49 +01:00
vcoppe
d476f0991e New translations minify.mdx (Vietnamese) 2025-11-12 17:56:47 +01:00
vcoppe
8da76ebf95 New translations minify.mdx (Chinese Simplified) 2025-11-12 17:56:46 +01:00
vcoppe
0db93bf1f7 New translations minify.mdx (Ukrainian) 2025-11-12 17:56:45 +01:00
vcoppe
7480d5e2d5 New translations minify.mdx (Turkish) 2025-11-12 17:56:44 +01:00
vcoppe
88da695b9f New translations minify.mdx (Swedish) 2025-11-12 17:56:43 +01:00
vcoppe
54cc725550 New translations minify.mdx (Russian) 2025-11-12 17:56:42 +01:00
vcoppe
bf299220fe New translations minify.mdx (Portuguese) 2025-11-12 17:56:40 +01:00
vcoppe
c726aefa87 New translations minify.mdx (Polish) 2025-11-12 17:56:39 +01:00
vcoppe
15a5d9a457 New translations minify.mdx (Norwegian) 2025-11-12 17:56:38 +01:00
vcoppe
e01223f985 New translations minify.mdx (Dutch) 2025-11-12 17:56:37 +01:00
vcoppe
ab6389901e New translations minify.mdx (Lithuanian) 2025-11-12 17:56:35 +01:00
vcoppe
281fec42cb New translations minify.mdx (Korean) 2025-11-12 17:56:34 +01:00
vcoppe
ec0f463be9 New translations minify.mdx (Italian) 2025-11-12 17:56:33 +01:00
vcoppe
a51a616181 New translations minify.mdx (Hungarian) 2025-11-12 17:56:32 +01:00
vcoppe
11a8ed3388 New translations minify.mdx (Hebrew) 2025-11-12 17:56:31 +01:00
vcoppe
1870ca9a6c New translations minify.mdx (Finnish) 2025-11-12 17:56:29 +01:00
vcoppe
4bc6ec4eea New translations minify.mdx (Basque) 2025-11-12 17:56:28 +01:00
vcoppe
82ba54e592 New translations minify.mdx (Greek) 2025-11-12 17:56:27 +01:00
vcoppe
3ef4a6d9b7 New translations minify.mdx (German) 2025-11-12 17:56:26 +01:00
vcoppe
d724799258 New translations minify.mdx (Danish) 2025-11-12 17:56:24 +01:00
vcoppe
e0cda2a2c0 New translations minify.mdx (Czech) 2025-11-12 17:56:23 +01:00
vcoppe
118584debb New translations minify.mdx (Catalan) 2025-11-12 17:56:22 +01:00
vcoppe
fb9c242972 New translations minify.mdx (Belarusian) 2025-11-12 17:56:20 +01:00
vcoppe
448838fde4 New translations minify.mdx (Spanish) 2025-11-12 17:56:19 +01:00
vcoppe
a6c4332854 New translations minify.mdx (French) 2025-11-12 17:56:18 +01:00
vcoppe
f55e2eb1f3 New translations minify.mdx (Romanian) 2025-11-12 17:56:17 +01:00
vcoppe
cfc5a00248 New translations merge.mdx (Serbian (Latin)) 2025-11-12 17:56:15 +01:00
vcoppe
8c508b0ca5 New translations merge.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:56:14 +01:00
vcoppe
67d6fe326f New translations merge.mdx (Latvian) 2025-11-12 17:56:12 +01:00
vcoppe
eadc9a62ce New translations merge.mdx (Thai) 2025-11-12 17:56:11 +01:00
vcoppe
b2b68316f2 New translations merge.mdx (Indonesian) 2025-11-12 17:56:10 +01:00
vcoppe
fac8938d0a New translations merge.mdx (Portuguese, Brazilian) 2025-11-12 17:56:09 +01:00
vcoppe
0f8c37ff70 New translations merge.mdx (Vietnamese) 2025-11-12 17:56:08 +01:00
vcoppe
fc004fd212 New translations merge.mdx (Chinese Simplified) 2025-11-12 17:56:06 +01:00
vcoppe
680a18f737 New translations merge.mdx (Ukrainian) 2025-11-12 17:56:05 +01:00
vcoppe
9b8533352b New translations merge.mdx (Turkish) 2025-11-12 17:56:04 +01:00
vcoppe
0761e6249d New translations merge.mdx (Swedish) 2025-11-12 17:56:03 +01:00
vcoppe
6b6636bc16 New translations merge.mdx (Russian) 2025-11-12 17:56:02 +01:00
vcoppe
f2da0e9baf New translations merge.mdx (Portuguese) 2025-11-12 17:56:00 +01:00
vcoppe
e82683a5e5 New translations merge.mdx (Polish) 2025-11-12 17:55:59 +01:00
vcoppe
9c59f27849 New translations merge.mdx (Norwegian) 2025-11-12 17:55:58 +01:00
vcoppe
6113af1ff5 New translations merge.mdx (Dutch) 2025-11-12 17:55:57 +01:00
vcoppe
1d535c4bba New translations merge.mdx (Lithuanian) 2025-11-12 17:55:56 +01:00
vcoppe
8cc022db94 New translations merge.mdx (Korean) 2025-11-12 17:55:54 +01:00
vcoppe
4a6e795595 New translations merge.mdx (Italian) 2025-11-12 17:55:53 +01:00
vcoppe
9453f65991 New translations merge.mdx (Hungarian) 2025-11-12 17:55:52 +01:00
vcoppe
7d5fa67aba New translations merge.mdx (Hebrew) 2025-11-12 17:55:50 +01:00
vcoppe
c5488d2f9b New translations merge.mdx (Finnish) 2025-11-12 17:55:49 +01:00
vcoppe
f1740d122e New translations merge.mdx (Basque) 2025-11-12 17:55:48 +01:00
vcoppe
d54eb810ae New translations merge.mdx (Greek) 2025-11-12 17:55:47 +01:00
vcoppe
cc77fbf7d1 New translations merge.mdx (German) 2025-11-12 17:55:46 +01:00
vcoppe
7e79a1af45 New translations merge.mdx (Danish) 2025-11-12 17:55:44 +01:00
vcoppe
0fd5929d33 New translations merge.mdx (Czech) 2025-11-12 17:55:43 +01:00
vcoppe
bfc2e87cbd New translations merge.mdx (Catalan) 2025-11-12 17:55:42 +01:00
vcoppe
6de7b71e5e New translations merge.mdx (Belarusian) 2025-11-12 17:55:41 +01:00
vcoppe
6773b21e70 New translations merge.mdx (Spanish) 2025-11-12 17:55:40 +01:00
vcoppe
da2538de26 New translations merge.mdx (French) 2025-11-12 17:55:38 +01:00
vcoppe
5f055d3c5e New translations merge.mdx (Romanian) 2025-11-12 17:55:37 +01:00
vcoppe
a53df616be New translations extract.mdx (Serbian (Latin)) 2025-11-12 17:55:36 +01:00
vcoppe
7d55a86209 New translations extract.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:55:34 +01:00
vcoppe
d0d0115ee7 New translations extract.mdx (Latvian) 2025-11-12 17:55:33 +01:00
vcoppe
630efd4ca2 New translations extract.mdx (Thai) 2025-11-12 17:55:32 +01:00
vcoppe
95f956e12c New translations extract.mdx (Indonesian) 2025-11-12 17:55:31 +01:00
vcoppe
26602696ce New translations extract.mdx (Portuguese, Brazilian) 2025-11-12 17:55:30 +01:00
vcoppe
65c5d4d950 New translations extract.mdx (Vietnamese) 2025-11-12 17:55:28 +01:00
vcoppe
765f79b7fd New translations extract.mdx (Chinese Simplified) 2025-11-12 17:55:27 +01:00
vcoppe
f822939633 New translations extract.mdx (Ukrainian) 2025-11-12 17:55:26 +01:00
vcoppe
d186843ba2 New translations extract.mdx (Turkish) 2025-11-12 17:55:25 +01:00
vcoppe
ba7ec47a07 New translations extract.mdx (Swedish) 2025-11-12 17:55:23 +01:00
vcoppe
7ce0adc245 New translations extract.mdx (Russian) 2025-11-12 17:55:22 +01:00
vcoppe
6c9a93bc75 New translations extract.mdx (Portuguese) 2025-11-12 17:55:21 +01:00
vcoppe
a09bf4dfa9 New translations extract.mdx (Polish) 2025-11-12 17:55:20 +01:00
vcoppe
6de834b55a New translations extract.mdx (Norwegian) 2025-11-12 17:55:19 +01:00
vcoppe
b2447718e3 New translations extract.mdx (Dutch) 2025-11-12 17:55:18 +01:00
vcoppe
e3e1746d42 New translations extract.mdx (Lithuanian) 2025-11-12 17:55:16 +01:00
vcoppe
e013c3aa92 New translations extract.mdx (Korean) 2025-11-12 17:55:15 +01:00
vcoppe
8d38cc7efe New translations extract.mdx (Italian) 2025-11-12 17:55:14 +01:00
vcoppe
37f17968b7 New translations extract.mdx (Hungarian) 2025-11-12 17:55:13 +01:00
vcoppe
2a2789dcba New translations extract.mdx (Hebrew) 2025-11-12 17:55:11 +01:00
vcoppe
b36e2b8d82 New translations extract.mdx (Finnish) 2025-11-12 17:55:10 +01:00
vcoppe
22dbe9d07d New translations extract.mdx (Basque) 2025-11-12 17:55:09 +01:00
vcoppe
2fb28a5a4a New translations extract.mdx (Greek) 2025-11-12 17:55:08 +01:00
vcoppe
0ea0e9992e New translations extract.mdx (German) 2025-11-12 17:55:07 +01:00
vcoppe
0f2f9703b2 New translations extract.mdx (Danish) 2025-11-12 17:55:05 +01:00
vcoppe
403f018b43 New translations extract.mdx (Czech) 2025-11-12 17:55:04 +01:00
vcoppe
35033bf439 New translations extract.mdx (Catalan) 2025-11-12 17:55:03 +01:00
vcoppe
be30315718 New translations extract.mdx (Belarusian) 2025-11-12 17:55:01 +01:00
vcoppe
046e7382cf New translations extract.mdx (Spanish) 2025-11-12 17:55:00 +01:00
vcoppe
ad4a0485c4 New translations extract.mdx (French) 2025-11-12 17:54:59 +01:00
vcoppe
c6aface832 New translations extract.mdx (Romanian) 2025-11-12 17:54:58 +01:00
vcoppe
651e1e9203 New translations clean.mdx (Serbian (Latin)) 2025-11-12 17:54:57 +01:00
vcoppe
e3dcf6eb87 New translations clean.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:54:56 +01:00
vcoppe
15d8b22025 New translations clean.mdx (Latvian) 2025-11-12 17:54:54 +01:00
vcoppe
700c8b565f New translations clean.mdx (Thai) 2025-11-12 17:54:53 +01:00
vcoppe
a0a888b31f New translations clean.mdx (Indonesian) 2025-11-12 17:54:52 +01:00
vcoppe
32f99c6c73 New translations clean.mdx (Portuguese, Brazilian) 2025-11-12 17:54:51 +01:00
vcoppe
d646a05222 New translations clean.mdx (Vietnamese) 2025-11-12 17:54:49 +01:00
vcoppe
3b2e49b524 New translations clean.mdx (Chinese Simplified) 2025-11-12 17:54:48 +01:00
vcoppe
5b1d415149 New translations clean.mdx (Ukrainian) 2025-11-12 17:54:47 +01:00
vcoppe
03cb33ed04 New translations clean.mdx (Turkish) 2025-11-12 17:54:46 +01:00
vcoppe
3aff104429 New translations clean.mdx (Swedish) 2025-11-12 17:54:44 +01:00
vcoppe
50c4a27ce8 New translations clean.mdx (Russian) 2025-11-12 17:54:43 +01:00
vcoppe
00ca6db8fc New translations clean.mdx (Portuguese) 2025-11-12 17:54:42 +01:00
vcoppe
1f43ccd2f2 New translations clean.mdx (Polish) 2025-11-12 17:54:40 +01:00
vcoppe
d86bd0a26b New translations clean.mdx (Norwegian) 2025-11-12 17:54:39 +01:00
vcoppe
f85bc96f73 New translations clean.mdx (Dutch) 2025-11-12 17:54:38 +01:00
vcoppe
96f89e5329 New translations clean.mdx (Lithuanian) 2025-11-12 17:54:36 +01:00
vcoppe
d2fea652e4 New translations clean.mdx (Korean) 2025-11-12 17:54:35 +01:00
vcoppe
ffb1e23ac0 New translations clean.mdx (Italian) 2025-11-12 17:54:34 +01:00
vcoppe
5ef0316099 New translations clean.mdx (Hungarian) 2025-11-12 17:54:33 +01:00
vcoppe
42168c2cd5 New translations clean.mdx (Hebrew) 2025-11-12 17:54:31 +01:00
vcoppe
5133bf3768 New translations clean.mdx (Finnish) 2025-11-12 17:54:30 +01:00
vcoppe
f1497a0caa New translations clean.mdx (Basque) 2025-11-12 17:54:29 +01:00
vcoppe
0fd7cd5c07 New translations clean.mdx (Greek) 2025-11-12 17:54:28 +01:00
vcoppe
48c35dcf17 New translations clean.mdx (German) 2025-11-12 17:54:27 +01:00
vcoppe
c5982f725c New translations clean.mdx (Danish) 2025-11-12 17:54:26 +01:00
vcoppe
063e567a09 New translations clean.mdx (Czech) 2025-11-12 17:54:24 +01:00
vcoppe
a0b067d92e New translations clean.mdx (Catalan) 2025-11-12 17:54:23 +01:00
vcoppe
47d4c9cb20 New translations clean.mdx (Belarusian) 2025-11-12 17:54:22 +01:00
vcoppe
585ef679f4 New translations clean.mdx (Spanish) 2025-11-12 17:54:21 +01:00
vcoppe
62f5851e25 New translations clean.mdx (French) 2025-11-12 17:54:19 +01:00
vcoppe
a6f81c03d2 New translations clean.mdx (Romanian) 2025-11-12 17:54:18 +01:00
vcoppe
70192b2cb9 New translations view.mdx (Serbian (Latin)) 2025-11-12 17:54:03 +01:00
vcoppe
9578aee988 New translations view.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:54:02 +01:00
vcoppe
f91dee0074 New translations view.mdx (Latvian) 2025-11-12 17:54:01 +01:00
vcoppe
fd2ebd10f5 New translations view.mdx (Thai) 2025-11-12 17:53:59 +01:00
vcoppe
d8db4d2a73 New translations view.mdx (Indonesian) 2025-11-12 17:53:58 +01:00
vcoppe
ebccd487be New translations view.mdx (Portuguese, Brazilian) 2025-11-12 17:53:57 +01:00
vcoppe
42a1a73dbf New translations view.mdx (Vietnamese) 2025-11-12 17:53:56 +01:00
vcoppe
7b543b65a6 New translations view.mdx (Chinese Simplified) 2025-11-12 17:53:54 +01:00
vcoppe
9d870faa1e New translations view.mdx (Ukrainian) 2025-11-12 17:53:53 +01:00
vcoppe
b514595b24 New translations view.mdx (Turkish) 2025-11-12 17:53:52 +01:00
vcoppe
ea6d9783fd New translations view.mdx (Swedish) 2025-11-12 17:53:50 +01:00
vcoppe
6686d48249 New translations view.mdx (Russian) 2025-11-12 17:53:49 +01:00
vcoppe
94a243b3b9 New translations view.mdx (Portuguese) 2025-11-12 17:53:48 +01:00
vcoppe
284fec9857 New translations view.mdx (Polish) 2025-11-12 17:53:47 +01:00
vcoppe
36b0b82625 New translations view.mdx (Norwegian) 2025-11-12 17:53:45 +01:00
vcoppe
0f0628024f New translations view.mdx (Dutch) 2025-11-12 17:53:44 +01:00
vcoppe
3633448af5 New translations view.mdx (Lithuanian) 2025-11-12 17:53:43 +01:00
vcoppe
ef500ac6f7 New translations view.mdx (Korean) 2025-11-12 17:53:41 +01:00
vcoppe
2678ce6948 New translations view.mdx (Italian) 2025-11-12 17:53:40 +01:00
vcoppe
1f2bca7271 New translations view.mdx (Hungarian) 2025-11-12 17:53:39 +01:00
vcoppe
ff31897603 New translations view.mdx (Hebrew) 2025-11-12 17:53:38 +01:00
vcoppe
930930fc43 New translations view.mdx (Finnish) 2025-11-12 17:53:37 +01:00
vcoppe
158120e396 New translations view.mdx (Basque) 2025-11-12 17:53:35 +01:00
vcoppe
ae61d76e3f New translations view.mdx (Greek) 2025-11-12 17:53:34 +01:00
vcoppe
3df66d5653 New translations view.mdx (German) 2025-11-12 17:53:33 +01:00
vcoppe
f10a27f882 New translations view.mdx (Danish) 2025-11-12 17:53:32 +01:00
vcoppe
88b776b415 New translations view.mdx (Czech) 2025-11-12 17:53:31 +01:00
vcoppe
d3e0f65e9e New translations view.mdx (Catalan) 2025-11-12 17:53:29 +01:00
vcoppe
666554bf18 New translations view.mdx (Belarusian) 2025-11-12 17:53:28 +01:00
vcoppe
fc722177e8 New translations view.mdx (Spanish) 2025-11-12 17:53:27 +01:00
vcoppe
d5a3f32811 New translations view.mdx (French) 2025-11-12 17:53:26 +01:00
vcoppe
7bbbe8d861 New translations view.mdx (Romanian) 2025-11-12 17:53:25 +01:00
vcoppe
ddc6f2e10b New translations settings.mdx (Serbian (Latin)) 2025-11-12 17:53:24 +01:00
vcoppe
ff78e98ae2 New translations settings.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:53:22 +01:00
vcoppe
ecf54ea6da New translations settings.mdx (Latvian) 2025-11-12 17:53:21 +01:00
vcoppe
ca0e1110ca New translations settings.mdx (Thai) 2025-11-12 17:53:20 +01:00
vcoppe
ca128d332f New translations settings.mdx (Indonesian) 2025-11-12 17:53:19 +01:00
vcoppe
ad027cb58c New translations settings.mdx (Portuguese, Brazilian) 2025-11-12 17:53:18 +01:00
vcoppe
ff7f1d7f67 New translations settings.mdx (Vietnamese) 2025-11-12 17:53:16 +01:00
vcoppe
ded9f33313 New translations settings.mdx (Chinese Simplified) 2025-11-12 17:53:15 +01:00
vcoppe
ccbc9211ac New translations settings.mdx (Ukrainian) 2025-11-12 17:53:14 +01:00
vcoppe
85684fe415 New translations settings.mdx (Turkish) 2025-11-12 17:53:13 +01:00
vcoppe
aeb60c50ee New translations settings.mdx (Swedish) 2025-11-12 17:53:12 +01:00
vcoppe
f81405e409 New translations settings.mdx (Russian) 2025-11-12 17:53:11 +01:00
vcoppe
294d85e33b New translations settings.mdx (Portuguese) 2025-11-12 17:53:09 +01:00
vcoppe
2d52865487 New translations settings.mdx (Polish) 2025-11-12 17:53:08 +01:00
vcoppe
cdba2179ff New translations settings.mdx (Norwegian) 2025-11-12 17:53:07 +01:00
vcoppe
60a521373d New translations settings.mdx (Dutch) 2025-11-12 17:53:06 +01:00
vcoppe
1bb65ea66f New translations settings.mdx (Lithuanian) 2025-11-12 17:53:05 +01:00
vcoppe
d0ceecbf51 New translations settings.mdx (Korean) 2025-11-12 17:53:03 +01:00
vcoppe
57af9b9ece New translations settings.mdx (Italian) 2025-11-12 17:53:02 +01:00
vcoppe
27d10bf9fe New translations settings.mdx (Hungarian) 2025-11-12 17:53:01 +01:00
vcoppe
0ee0814779 New translations settings.mdx (Hebrew) 2025-11-12 17:53:00 +01:00
vcoppe
384af64599 New translations settings.mdx (Finnish) 2025-11-12 17:52:58 +01:00
vcoppe
a4f06e5196 New translations settings.mdx (Basque) 2025-11-12 17:52:57 +01:00
vcoppe
150b7878fc New translations settings.mdx (Greek) 2025-11-12 17:52:56 +01:00
vcoppe
da83cc1649 New translations settings.mdx (German) 2025-11-12 17:52:54 +01:00
vcoppe
5be4e6816d New translations settings.mdx (Danish) 2025-11-12 17:52:53 +01:00
vcoppe
9769d7f9ec New translations settings.mdx (Czech) 2025-11-12 17:52:52 +01:00
vcoppe
d7b843db26 New translations settings.mdx (Catalan) 2025-11-12 17:52:50 +01:00
vcoppe
23ee01a8e9 New translations settings.mdx (Belarusian) 2025-11-12 17:52:49 +01:00
vcoppe
adc2197d98 New translations settings.mdx (Spanish) 2025-11-12 17:52:48 +01:00
vcoppe
f5c95fe0f4 New translations settings.mdx (French) 2025-11-12 17:52:47 +01:00
vcoppe
38523f1c42 New translations settings.mdx (Romanian) 2025-11-12 17:52:45 +01:00
vcoppe
747d3b28ce New translations file.mdx (Serbian (Latin)) 2025-11-12 17:52:44 +01:00
vcoppe
a63683d894 New translations file.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:52:43 +01:00
vcoppe
13907f2b5c New translations file.mdx (Latvian) 2025-11-12 17:52:41 +01:00
vcoppe
9d448cb8d4 New translations file.mdx (Thai) 2025-11-12 17:52:40 +01:00
vcoppe
7647ca0762 New translations file.mdx (Indonesian) 2025-11-12 17:52:39 +01:00
vcoppe
da9021c3ff New translations file.mdx (Portuguese, Brazilian) 2025-11-12 17:52:38 +01:00
vcoppe
5a8232167a New translations file.mdx (Vietnamese) 2025-11-12 17:52:37 +01:00
vcoppe
f937c9750d New translations file.mdx (Chinese Simplified) 2025-11-12 17:52:35 +01:00
vcoppe
65a10b2daa New translations file.mdx (Ukrainian) 2025-11-12 17:52:34 +01:00
vcoppe
20c87a14aa New translations file.mdx (Turkish) 2025-11-12 17:52:33 +01:00
vcoppe
f77571b7ef New translations file.mdx (Swedish) 2025-11-12 17:52:32 +01:00
vcoppe
9e95c4b3e1 New translations file.mdx (Russian) 2025-11-12 17:52:30 +01:00
vcoppe
92e46ec540 New translations file.mdx (Portuguese) 2025-11-12 17:52:29 +01:00
vcoppe
1713437813 New translations file.mdx (Polish) 2025-11-12 17:52:28 +01:00
vcoppe
5bf88a34f7 New translations file.mdx (Norwegian) 2025-11-12 17:52:26 +01:00
vcoppe
75c6a2facf New translations file.mdx (Dutch) 2025-11-12 17:52:25 +01:00
vcoppe
3c728115b8 New translations file.mdx (Lithuanian) 2025-11-12 17:52:24 +01:00
vcoppe
51d56dcf7a New translations file.mdx (Korean) 2025-11-12 17:52:23 +01:00
vcoppe
6fe0aa3ee6 New translations file.mdx (Italian) 2025-11-12 17:52:21 +01:00
vcoppe
670bccd2aa New translations file.mdx (Hungarian) 2025-11-12 17:52:20 +01:00
vcoppe
7cb253565e New translations file.mdx (Hebrew) 2025-11-12 17:52:19 +01:00
vcoppe
54e1900e6d New translations file.mdx (Finnish) 2025-11-12 17:52:18 +01:00
vcoppe
d83194b225 New translations file.mdx (Basque) 2025-11-12 17:52:16 +01:00
vcoppe
82eb9a1dc9 New translations file.mdx (Greek) 2025-11-12 17:52:15 +01:00
vcoppe
d298e51a6c New translations file.mdx (German) 2025-11-12 17:52:14 +01:00
vcoppe
2b7e3a2b5d New translations file.mdx (Danish) 2025-11-12 17:52:12 +01:00
vcoppe
0644952db3 New translations file.mdx (Czech) 2025-11-12 17:52:11 +01:00
vcoppe
08e5dc382f New translations file.mdx (Catalan) 2025-11-12 17:52:10 +01:00
vcoppe
afd3d5e8dc New translations file.mdx (Belarusian) 2025-11-12 17:52:09 +01:00
vcoppe
b2fa2d8685 New translations file.mdx (Spanish) 2025-11-12 17:52:07 +01:00
vcoppe
f8e5ccc5bb New translations file.mdx (French) 2025-11-12 17:52:06 +01:00
vcoppe
63b56166cb New translations file.mdx (Romanian) 2025-11-12 17:52:05 +01:00
vcoppe
357791d17c New translations edit.mdx (Serbian (Latin)) 2025-11-12 17:52:04 +01:00
vcoppe
d095f6734d New translations edit.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:52:03 +01:00
vcoppe
ea8771d6f5 New translations edit.mdx (Latvian) 2025-11-12 17:52:01 +01:00
vcoppe
c8e28127c3 New translations edit.mdx (Thai) 2025-11-12 17:52:00 +01:00
vcoppe
cae013a68d New translations edit.mdx (Indonesian) 2025-11-12 17:51:59 +01:00
vcoppe
caa56ee16b New translations edit.mdx (Portuguese, Brazilian) 2025-11-12 17:51:57 +01:00
vcoppe
80e11a7723 New translations edit.mdx (Vietnamese) 2025-11-12 17:51:56 +01:00
vcoppe
87b3126eb0 New translations edit.mdx (Chinese Simplified) 2025-11-12 17:51:55 +01:00
vcoppe
8388c8db23 New translations edit.mdx (Ukrainian) 2025-11-12 17:51:54 +01:00
vcoppe
2bce1dca84 New translations edit.mdx (Turkish) 2025-11-12 17:51:52 +01:00
vcoppe
749be1eb73 New translations edit.mdx (Swedish) 2025-11-12 17:51:51 +01:00
vcoppe
23b74a1d63 New translations edit.mdx (Russian) 2025-11-12 17:51:50 +01:00
vcoppe
b2f10c2f68 New translations edit.mdx (Portuguese) 2025-11-12 17:51:49 +01:00
vcoppe
2aa7f12514 New translations edit.mdx (Polish) 2025-11-12 17:51:47 +01:00
vcoppe
cea8cfbfa6 New translations edit.mdx (Norwegian) 2025-11-12 17:51:46 +01:00
vcoppe
e15275d96d New translations edit.mdx (Dutch) 2025-11-12 17:51:44 +01:00
vcoppe
3b6fb4d170 New translations edit.mdx (Lithuanian) 2025-11-12 17:51:43 +01:00
vcoppe
05c83c63e7 New translations edit.mdx (Korean) 2025-11-12 17:51:42 +01:00
vcoppe
470ca982a1 New translations edit.mdx (Italian) 2025-11-12 17:51:41 +01:00
vcoppe
ec62477937 New translations edit.mdx (Hungarian) 2025-11-12 17:51:40 +01:00
vcoppe
fed8474dce New translations edit.mdx (Hebrew) 2025-11-12 17:51:38 +01:00
vcoppe
7dc93302bf New translations edit.mdx (Finnish) 2025-11-12 17:51:37 +01:00
vcoppe
3105c444ce New translations edit.mdx (Basque) 2025-11-12 17:51:36 +01:00
vcoppe
a2cae2e086 New translations edit.mdx (Greek) 2025-11-12 17:51:35 +01:00
vcoppe
cd4320fe08 New translations edit.mdx (German) 2025-11-12 17:51:33 +01:00
vcoppe
32b07b0e32 New translations edit.mdx (Danish) 2025-11-12 17:51:32 +01:00
vcoppe
3d6234d5b9 New translations edit.mdx (Czech) 2025-11-12 17:51:31 +01:00
vcoppe
2463c8b40a New translations edit.mdx (Catalan) 2025-11-12 17:51:30 +01:00
vcoppe
0a9206b1d3 New translations edit.mdx (Belarusian) 2025-11-12 17:51:28 +01:00
vcoppe
7cd1c0dd69 New translations edit.mdx (Spanish) 2025-11-12 17:51:27 +01:00
vcoppe
3111927f02 New translations edit.mdx (French) 2025-11-12 17:51:26 +01:00
vcoppe
c471eb160c New translations edit.mdx (Romanian) 2025-11-12 17:51:24 +01:00
vcoppe
c983abb49f New translations map-controls.mdx (Serbian (Latin)) 2025-11-12 17:51:10 +01:00
vcoppe
5195e4f591 New translations map-controls.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:51:08 +01:00
vcoppe
538d04824d New translations map-controls.mdx (Latvian) 2025-11-12 17:51:06 +01:00
vcoppe
4666fa418d New translations map-controls.mdx (Thai) 2025-11-12 17:51:05 +01:00
vcoppe
87ce67c326 New translations map-controls.mdx (Indonesian) 2025-11-12 17:51:04 +01:00
vcoppe
6eaec189e6 New translations map-controls.mdx (Portuguese, Brazilian) 2025-11-12 17:51:03 +01:00
vcoppe
6f342951ce New translations map-controls.mdx (Vietnamese) 2025-11-12 17:51:01 +01:00
vcoppe
aacab6e62c New translations map-controls.mdx (Chinese Simplified) 2025-11-12 17:51:00 +01:00
vcoppe
cc7a07e872 New translations map-controls.mdx (Ukrainian) 2025-11-12 17:50:59 +01:00
vcoppe
9127031ac9 New translations map-controls.mdx (Turkish) 2025-11-12 17:50:58 +01:00
vcoppe
9aa654e69d New translations map-controls.mdx (Swedish) 2025-11-12 17:50:56 +01:00
vcoppe
573d4e58bb New translations map-controls.mdx (Russian) 2025-11-12 17:50:55 +01:00
vcoppe
e7c96e3b19 New translations map-controls.mdx (Portuguese) 2025-11-12 17:50:54 +01:00
vcoppe
c9bd41e58e New translations map-controls.mdx (Polish) 2025-11-12 17:50:53 +01:00
vcoppe
5027903820 New translations map-controls.mdx (Norwegian) 2025-11-12 17:50:51 +01:00
vcoppe
767ebc573a New translations map-controls.mdx (Dutch) 2025-11-12 17:50:50 +01:00
vcoppe
5a3d66c885 New translations map-controls.mdx (Lithuanian) 2025-11-12 17:50:49 +01:00
vcoppe
e84e6bd6c5 New translations map-controls.mdx (Korean) 2025-11-12 17:50:48 +01:00
vcoppe
bee6a7dacc New translations map-controls.mdx (Italian) 2025-11-12 17:50:47 +01:00
vcoppe
9876c7506c New translations map-controls.mdx (Hungarian) 2025-11-12 17:50:45 +01:00
vcoppe
afb477d155 New translations map-controls.mdx (Hebrew) 2025-11-12 17:50:44 +01:00
vcoppe
2c9a8491ca New translations map-controls.mdx (Finnish) 2025-11-12 17:50:43 +01:00
vcoppe
f6a9b37cc2 New translations map-controls.mdx (Basque) 2025-11-12 17:50:41 +01:00
vcoppe
1852214758 New translations map-controls.mdx (Greek) 2025-11-12 17:50:40 +01:00
vcoppe
a404591c57 New translations map-controls.mdx (German) 2025-11-12 17:50:39 +01:00
vcoppe
406f8012fc New translations map-controls.mdx (Danish) 2025-11-12 17:50:38 +01:00
vcoppe
9de5fa1819 New translations map-controls.mdx (Czech) 2025-11-12 17:50:36 +01:00
vcoppe
da7862546c New translations map-controls.mdx (Catalan) 2025-11-12 17:50:35 +01:00
vcoppe
8314ee0c30 New translations map-controls.mdx (Belarusian) 2025-11-12 17:50:34 +01:00
vcoppe
ba0da25c6c New translations map-controls.mdx (Spanish) 2025-11-12 17:50:32 +01:00
vcoppe
9b432cef1b New translations map-controls.mdx (French) 2025-11-12 17:50:31 +01:00
vcoppe
ada6adaadf New translations map-controls.mdx (Romanian) 2025-11-12 17:50:30 +01:00
vcoppe
4fe7d72e65 New translations translation.mdx (Serbian (Latin)) 2025-11-12 17:50:16 +01:00
vcoppe
aafc5df915 New translations translation.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:50:15 +01:00
vcoppe
d4cbaeb1e7 New translations translation.mdx (Latvian) 2025-11-12 17:50:13 +01:00
vcoppe
36300aaa45 New translations translation.mdx (Thai) 2025-11-12 17:50:12 +01:00
vcoppe
693800e0b8 New translations translation.mdx (Indonesian) 2025-11-12 17:50:11 +01:00
vcoppe
245c0eecc6 New translations translation.mdx (Portuguese, Brazilian) 2025-11-12 17:50:10 +01:00
vcoppe
25fe02602f New translations translation.mdx (Vietnamese) 2025-11-12 17:50:09 +01:00
vcoppe
5524ddc6a0 New translations translation.mdx (Chinese Simplified) 2025-11-12 17:50:07 +01:00
vcoppe
2fabbec217 New translations translation.mdx (Ukrainian) 2025-11-12 17:50:06 +01:00
vcoppe
3c080873e5 New translations translation.mdx (Turkish) 2025-11-12 17:50:05 +01:00
vcoppe
f252afb29f New translations translation.mdx (Swedish) 2025-11-12 17:50:04 +01:00
vcoppe
3109c0002f New translations translation.mdx (Russian) 2025-11-12 17:50:02 +01:00
vcoppe
059d7d6ae5 New translations translation.mdx (Portuguese) 2025-11-12 17:50:01 +01:00
vcoppe
58f159c0c9 New translations translation.mdx (Polish) 2025-11-12 17:50:00 +01:00
vcoppe
5a6c115121 New translations translation.mdx (Norwegian) 2025-11-12 17:49:59 +01:00
vcoppe
4cb85a543c New translations translation.mdx (Dutch) 2025-11-12 17:49:58 +01:00
vcoppe
d99c3e8f43 New translations translation.mdx (Lithuanian) 2025-11-12 17:49:56 +01:00
vcoppe
a2908b00ef New translations translation.mdx (Korean) 2025-11-12 17:49:55 +01:00
vcoppe
7b88e161e4 New translations translation.mdx (Italian) 2025-11-12 17:49:54 +01:00
vcoppe
96ea0d6ec0 New translations translation.mdx (Hungarian) 2025-11-12 17:49:53 +01:00
vcoppe
bbfed01367 New translations translation.mdx (Hebrew) 2025-11-12 17:49:52 +01:00
vcoppe
65d81a0d58 New translations translation.mdx (Finnish) 2025-11-12 17:49:50 +01:00
vcoppe
186908fd3b New translations translation.mdx (Basque) 2025-11-12 17:49:49 +01:00
vcoppe
ab43314a53 New translations translation.mdx (Greek) 2025-11-12 17:49:48 +01:00
vcoppe
ed1c76879d New translations translation.mdx (German) 2025-11-12 17:49:47 +01:00
vcoppe
38af47f2eb New translations translation.mdx (Danish) 2025-11-12 17:49:46 +01:00
vcoppe
302d84baf7 New translations translation.mdx (Czech) 2025-11-12 17:49:45 +01:00
vcoppe
2b43f5f74f New translations translation.mdx (Catalan) 2025-11-12 17:49:43 +01:00
vcoppe
e4630052d1 New translations translation.mdx (Belarusian) 2025-11-12 17:49:42 +01:00
vcoppe
8711f942c0 New translations translation.mdx (Spanish) 2025-11-12 17:49:41 +01:00
vcoppe
5282a6e045 New translations translation.mdx (French) 2025-11-12 17:49:40 +01:00
vcoppe
55cbbfa920 New translations translation.mdx (Romanian) 2025-11-12 17:49:39 +01:00
vcoppe
68371d716b New translations funding.mdx (Serbian (Latin)) 2025-11-12 17:49:25 +01:00
vcoppe
027c0b856f New translations funding.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:49:24 +01:00
vcoppe
422978e404 New translations funding.mdx (Latvian) 2025-11-12 17:49:22 +01:00
vcoppe
8b5ce045e4 New translations funding.mdx (Thai) 2025-11-12 17:49:21 +01:00
vcoppe
86f126e735 New translations funding.mdx (Indonesian) 2025-11-12 17:49:20 +01:00
vcoppe
0e3898c065 New translations funding.mdx (Portuguese, Brazilian) 2025-11-12 17:49:19 +01:00
vcoppe
1e3a74643e New translations funding.mdx (Vietnamese) 2025-11-12 17:49:18 +01:00
vcoppe
4792689998 New translations funding.mdx (Chinese Simplified) 2025-11-12 17:49:17 +01:00
vcoppe
0eec7f0813 New translations funding.mdx (Ukrainian) 2025-11-12 17:49:15 +01:00
vcoppe
2c1bf56638 New translations funding.mdx (Turkish) 2025-11-12 17:49:14 +01:00
vcoppe
57a4279647 New translations funding.mdx (Swedish) 2025-11-12 17:49:13 +01:00
vcoppe
f9146d62cf New translations funding.mdx (Russian) 2025-11-12 17:49:12 +01:00
vcoppe
694528a294 New translations funding.mdx (Portuguese) 2025-11-12 17:49:11 +01:00
vcoppe
f11968d2c6 New translations funding.mdx (Polish) 2025-11-12 17:49:09 +01:00
vcoppe
b64b354f36 New translations funding.mdx (Norwegian) 2025-11-12 17:49:08 +01:00
vcoppe
0cb7b61043 New translations funding.mdx (Dutch) 2025-11-12 17:49:07 +01:00
vcoppe
b45fb37b02 New translations funding.mdx (Lithuanian) 2025-11-12 17:49:06 +01:00
vcoppe
bfbb25c71e New translations funding.mdx (Korean) 2025-11-12 17:49:04 +01:00
vcoppe
8a0e89c14d New translations funding.mdx (Italian) 2025-11-12 17:49:03 +01:00
vcoppe
8c7573b669 New translations funding.mdx (Hungarian) 2025-11-12 17:49:02 +01:00
vcoppe
0cae837e07 New translations funding.mdx (Hebrew) 2025-11-12 17:49:01 +01:00
vcoppe
ef36fb567a New translations funding.mdx (Finnish) 2025-11-12 17:48:59 +01:00
vcoppe
5431fe1eac New translations funding.mdx (Basque) 2025-11-12 17:48:58 +01:00
vcoppe
07b4e4b59d New translations funding.mdx (Greek) 2025-11-12 17:48:57 +01:00
vcoppe
fa06da7f03 New translations funding.mdx (German) 2025-11-12 17:48:56 +01:00
vcoppe
6cc6a34b74 New translations funding.mdx (Danish) 2025-11-12 17:48:55 +01:00
vcoppe
7187c9ce71 New translations funding.mdx (Czech) 2025-11-12 17:48:54 +01:00
vcoppe
b209ce2524 New translations funding.mdx (Catalan) 2025-11-12 17:48:52 +01:00
vcoppe
c37dd605cc New translations funding.mdx (Belarusian) 2025-11-12 17:48:51 +01:00
vcoppe
d0a3ea99bd New translations funding.mdx (Spanish) 2025-11-12 17:48:50 +01:00
vcoppe
80077b92c4 New translations funding.mdx (French) 2025-11-12 17:48:49 +01:00
vcoppe
dcf919e440 New translations funding.mdx (Romanian) 2025-11-12 17:48:48 +01:00
vcoppe
48569e05e8 New translations gpx.mdx (Serbian (Latin)) 2025-11-12 17:48:46 +01:00
vcoppe
60b24e9c3b New translations gpx.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:48:45 +01:00
vcoppe
fd4a97f547 New translations gpx.mdx (Latvian) 2025-11-12 17:48:44 +01:00
vcoppe
8b348aeea9 New translations gpx.mdx (Thai) 2025-11-12 17:48:43 +01:00
vcoppe
6d0400e85e New translations gpx.mdx (Indonesian) 2025-11-12 17:48:41 +01:00
vcoppe
d60cb68430 New translations gpx.mdx (Portuguese, Brazilian) 2025-11-12 17:48:40 +01:00
vcoppe
5834940965 New translations gpx.mdx (Vietnamese) 2025-11-12 17:48:39 +01:00
vcoppe
2680d3eb5c New translations gpx.mdx (Chinese Simplified) 2025-11-12 17:48:38 +01:00
vcoppe
149a1e5b0d New translations gpx.mdx (Ukrainian) 2025-11-12 17:48:36 +01:00
vcoppe
1e4a0bfb8e New translations gpx.mdx (Turkish) 2025-11-12 17:48:35 +01:00
vcoppe
ba4c29c44d New translations gpx.mdx (Swedish) 2025-11-12 17:48:33 +01:00
vcoppe
a632bedb4c New translations gpx.mdx (Russian) 2025-11-12 17:48:32 +01:00
vcoppe
fc8deeb0c5 New translations gpx.mdx (Portuguese) 2025-11-12 17:48:31 +01:00
vcoppe
e9e462b020 New translations gpx.mdx (Polish) 2025-11-12 17:48:30 +01:00
vcoppe
713d2cd7ee New translations gpx.mdx (Norwegian) 2025-11-12 17:48:28 +01:00
vcoppe
dea37b2e83 New translations gpx.mdx (Dutch) 2025-11-12 17:48:27 +01:00
vcoppe
95e02956fb New translations gpx.mdx (Lithuanian) 2025-11-12 17:48:26 +01:00
vcoppe
87ef568de0 New translations gpx.mdx (Korean) 2025-11-12 17:48:25 +01:00
vcoppe
a4c407e3ab New translations gpx.mdx (Italian) 2025-11-12 17:48:23 +01:00
vcoppe
543af45daa New translations gpx.mdx (Hungarian) 2025-11-12 17:48:22 +01:00
vcoppe
aca0442928 New translations gpx.mdx (Hebrew) 2025-11-12 17:48:21 +01:00
vcoppe
af1cc42b0d New translations gpx.mdx (Finnish) 2025-11-12 17:48:19 +01:00
vcoppe
1bca40eb6a New translations gpx.mdx (Basque) 2025-11-12 17:48:18 +01:00
vcoppe
2c099c3a2a New translations gpx.mdx (Greek) 2025-11-12 17:48:17 +01:00
vcoppe
1afd9fdab2 New translations gpx.mdx (German) 2025-11-12 17:48:16 +01:00
vcoppe
522eea1371 New translations gpx.mdx (Danish) 2025-11-12 17:48:15 +01:00
vcoppe
e9fc115bdd New translations gpx.mdx (Czech) 2025-11-12 17:48:13 +01:00
vcoppe
711e4a84f8 New translations gpx.mdx (Catalan) 2025-11-12 17:48:12 +01:00
vcoppe
ac6b9c855a New translations gpx.mdx (Belarusian) 2025-11-12 17:48:11 +01:00
vcoppe
eb1a9f8b9f New translations gpx.mdx (Spanish) 2025-11-12 17:48:09 +01:00
vcoppe
4fc08faa48 New translations gpx.mdx (French) 2025-11-12 17:48:08 +01:00
vcoppe
4dcf6b672c New translations gpx.mdx (Romanian) 2025-11-12 17:48:07 +01:00
vcoppe
36c8d8668a New translations files-and-stats.mdx (Serbian (Latin)) 2025-11-12 17:47:53 +01:00
vcoppe
ffe3d1f5bb New translations files-and-stats.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:47:51 +01:00
vcoppe
260cad776e New translations files-and-stats.mdx (Latvian) 2025-11-12 17:47:50 +01:00
vcoppe
3cb6fd1f44 New translations files-and-stats.mdx (Thai) 2025-11-12 17:47:49 +01:00
vcoppe
8f0d0b24c6 New translations files-and-stats.mdx (Indonesian) 2025-11-12 17:47:48 +01:00
vcoppe
f80a06b258 New translations files-and-stats.mdx (Portuguese, Brazilian) 2025-11-12 17:47:46 +01:00
vcoppe
de1ce49ba3 New translations files-and-stats.mdx (Vietnamese) 2025-11-12 17:47:45 +01:00
vcoppe
f20b26809d New translations files-and-stats.mdx (Chinese Simplified) 2025-11-12 17:47:44 +01:00
vcoppe
dbf788976f New translations files-and-stats.mdx (Ukrainian) 2025-11-12 17:47:43 +01:00
vcoppe
2f8cd71c51 New translations files-and-stats.mdx (Turkish) 2025-11-12 17:47:41 +01:00
vcoppe
ea9757a288 New translations files-and-stats.mdx (Swedish) 2025-11-12 17:47:40 +01:00
vcoppe
782ca0c11a New translations files-and-stats.mdx (Russian) 2025-11-12 17:47:39 +01:00
vcoppe
56d5474bd7 New translations files-and-stats.mdx (Portuguese) 2025-11-12 17:47:37 +01:00
vcoppe
683c3a6122 New translations files-and-stats.mdx (Polish) 2025-11-12 17:47:36 +01:00
vcoppe
b89dc25959 New translations files-and-stats.mdx (Norwegian) 2025-11-12 17:47:35 +01:00
vcoppe
ab09fe19d8 New translations routing.mdx (Czech) 2025-11-12 17:47:33 +01:00
vcoppe
d16cd4bf8e New translations files-and-stats.mdx (Dutch) 2025-11-12 17:47:32 +01:00
vcoppe
2e853006a5 New translations files-and-stats.mdx (Lithuanian) 2025-11-12 17:47:31 +01:00
vcoppe
fbfaf858e6 New translations files-and-stats.mdx (Korean) 2025-11-12 17:47:30 +01:00
vcoppe
70511c3ece New translations files-and-stats.mdx (Italian) 2025-11-12 17:47:28 +01:00
vcoppe
7c529eefb3 New translations files-and-stats.mdx (Hungarian) 2025-11-12 17:47:27 +01:00
vcoppe
bb794cf8f3 New translations files-and-stats.mdx (Hebrew) 2025-11-12 17:47:26 +01:00
vcoppe
20ae712719 New translations files-and-stats.mdx (Finnish) 2025-11-12 17:47:25 +01:00
vcoppe
607cd21f41 New translations files-and-stats.mdx (Basque) 2025-11-12 17:47:24 +01:00
vcoppe
754394e1a8 New translations files-and-stats.mdx (Greek) 2025-11-12 17:47:22 +01:00
vcoppe
7a8fcb46c7 New translations files-and-stats.mdx (German) 2025-11-12 17:47:21 +01:00
vcoppe
59b5a5068d New translations files-and-stats.mdx (Danish) 2025-11-12 17:47:20 +01:00
vcoppe
d65bdc103d New translations files-and-stats.mdx (Czech) 2025-11-12 17:47:19 +01:00
vcoppe
aefd924f05 New translations files-and-stats.mdx (Catalan) 2025-11-12 17:47:17 +01:00
vcoppe
96018b9e6a New translations files-and-stats.mdx (Belarusian) 2025-11-12 17:47:16 +01:00
vcoppe
3865d52c29 New translations files-and-stats.mdx (Spanish) 2025-11-12 17:47:15 +01:00
vcoppe
20c8133301 New translations files-and-stats.mdx (French) 2025-11-12 17:47:14 +01:00
vcoppe
9705f56e2f New translations files-and-stats.mdx (Romanian) 2025-11-12 17:47:12 +01:00
vcoppe
f4d2459a10 New translations elevation.mdx (Serbian (Latin)) 2025-11-12 17:43:59 +01:00
vcoppe
7073baea6e New translations elevation.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:43:58 +01:00
vcoppe
7834e6294c New translations elevation.mdx (Latvian) 2025-11-12 17:43:56 +01:00
vcoppe
a2d2d79ba4 New translations elevation.mdx (Thai) 2025-11-12 17:43:55 +01:00
vcoppe
087132a8b3 New translations elevation.mdx (Indonesian) 2025-11-12 17:43:54 +01:00
vcoppe
e62eca1fdf New translations elevation.mdx (Portuguese, Brazilian) 2025-11-12 17:43:53 +01:00
vcoppe
f0333d0f68 New translations elevation.mdx (Vietnamese) 2025-11-12 17:43:52 +01:00
vcoppe
08e39ae6e0 New translations elevation.mdx (Chinese Simplified) 2025-11-12 17:43:50 +01:00
vcoppe
128484dd73 New translations elevation.mdx (Ukrainian) 2025-11-12 17:43:49 +01:00
vcoppe
1e2d14af4c New translations elevation.mdx (Turkish) 2025-11-12 17:43:48 +01:00
vcoppe
37e3b86a36 New translations elevation.mdx (Swedish) 2025-11-12 17:43:47 +01:00
vcoppe
13e49b7f2c New translations elevation.mdx (Russian) 2025-11-12 17:43:45 +01:00
vcoppe
8c3e556071 New translations elevation.mdx (Portuguese) 2025-11-12 17:43:44 +01:00
vcoppe
7a24f95566 New translations elevation.mdx (Polish) 2025-11-12 17:43:43 +01:00
vcoppe
dbc14f071a New translations elevation.mdx (Norwegian) 2025-11-12 17:43:42 +01:00
vcoppe
b75b5dc107 New translations elevation.mdx (Dutch) 2025-11-12 17:43:41 +01:00
vcoppe
f1f6f3b0a8 New translations elevation.mdx (Lithuanian) 2025-11-12 17:43:39 +01:00
vcoppe
a3f556a136 New translations elevation.mdx (Korean) 2025-11-12 17:43:38 +01:00
vcoppe
04ec1d3df4 New translations elevation.mdx (Italian) 2025-11-12 17:43:37 +01:00
vcoppe
da8000787d New translations elevation.mdx (Hungarian) 2025-11-12 17:43:36 +01:00
vcoppe
faa4e917f1 New translations elevation.mdx (Hebrew) 2025-11-12 17:43:34 +01:00
vcoppe
99dc05f55b New translations elevation.mdx (Finnish) 2025-11-12 17:43:33 +01:00
vcoppe
0f2c0b8cc7 New translations elevation.mdx (Basque) 2025-11-12 17:43:32 +01:00
vcoppe
c4b1e75b0c New translations elevation.mdx (Greek) 2025-11-12 17:43:31 +01:00
vcoppe
b2eb932a06 New translations elevation.mdx (German) 2025-11-12 17:43:29 +01:00
vcoppe
aac6b15c77 New translations elevation.mdx (Danish) 2025-11-12 17:43:28 +01:00
vcoppe
2b4f1e3203 New translations elevation.mdx (Czech) 2025-11-12 17:43:27 +01:00
vcoppe
386320e12c New translations elevation.mdx (Catalan) 2025-11-12 17:43:26 +01:00
vcoppe
e775e7918d New translations elevation.mdx (Belarusian) 2025-11-12 17:43:24 +01:00
vcoppe
e0f60ddf28 New translations elevation.mdx (Spanish) 2025-11-12 17:43:23 +01:00
vcoppe
7e9b9500d5 New translations elevation.mdx (French) 2025-11-12 17:43:22 +01:00
vcoppe
29b143fb50 New translations elevation.mdx (Romanian) 2025-11-12 17:43:21 +01:00
vcoppe
0a6f454bb4 New translations time.mdx (Serbian (Latin)) 2025-11-12 17:43:06 +01:00
vcoppe
ad21ab0a45 New translations time.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:43:05 +01:00
vcoppe
7958d32b90 New translations time.mdx (Latvian) 2025-11-12 17:43:04 +01:00
vcoppe
81bfb6184e New translations time.mdx (Thai) 2025-11-12 17:43:03 +01:00
vcoppe
79bc30cbe0 New translations time.mdx (Indonesian) 2025-11-12 17:43:01 +01:00
vcoppe
076d741453 New translations time.mdx (Portuguese, Brazilian) 2025-11-12 17:43:00 +01:00
vcoppe
dc36e2e9d7 New translations time.mdx (Vietnamese) 2025-11-12 17:42:58 +01:00
vcoppe
0a3da42af9 New translations time.mdx (Chinese Simplified) 2025-11-12 17:42:57 +01:00
vcoppe
019d2b0c1b New translations time.mdx (Ukrainian) 2025-11-12 17:42:56 +01:00
vcoppe
cf5c4bcd32 New translations time.mdx (Turkish) 2025-11-12 17:42:54 +01:00
vcoppe
8c813103f2 New translations time.mdx (Swedish) 2025-11-12 17:42:53 +01:00
vcoppe
ed97c83a4c New translations time.mdx (Russian) 2025-11-12 17:42:52 +01:00
vcoppe
a7dfb0b6d3 New translations time.mdx (Portuguese) 2025-11-12 17:42:51 +01:00
vcoppe
be544e6051 New translations time.mdx (Polish) 2025-11-12 17:42:49 +01:00
vcoppe
e457526e8f New translations time.mdx (Norwegian) 2025-11-12 17:42:48 +01:00
vcoppe
539e0a2045 New translations time.mdx (Dutch) 2025-11-12 17:42:47 +01:00
vcoppe
fdcfca7633 New translations time.mdx (Lithuanian) 2025-11-12 17:42:46 +01:00
vcoppe
bd0f0cabca New translations time.mdx (Korean) 2025-11-12 17:42:44 +01:00
vcoppe
a94edc6fe2 New translations time.mdx (Italian) 2025-11-12 17:42:43 +01:00
vcoppe
295ea93340 New translations time.mdx (Hungarian) 2025-11-12 17:42:42 +01:00
vcoppe
4af58fa882 New translations time.mdx (Hebrew) 2025-11-12 17:42:41 +01:00
vcoppe
9b4b10e3b6 New translations time.mdx (Finnish) 2025-11-12 17:42:40 +01:00
vcoppe
24f27fcfb4 New translations time.mdx (Basque) 2025-11-12 17:42:38 +01:00
vcoppe
725a8b7959 New translations time.mdx (Greek) 2025-11-12 17:42:37 +01:00
vcoppe
c7b5b4b7dd New translations time.mdx (German) 2025-11-12 17:42:36 +01:00
vcoppe
04ffafb3b7 New translations time.mdx (Danish) 2025-11-12 17:42:34 +01:00
vcoppe
485b6903e5 New translations time.mdx (Czech) 2025-11-12 17:42:33 +01:00
vcoppe
b55d79564c New translations time.mdx (Catalan) 2025-11-12 17:42:32 +01:00
vcoppe
e23de3adbc New translations time.mdx (Belarusian) 2025-11-12 17:42:31 +01:00
vcoppe
b97aa933a2 New translations time.mdx (Spanish) 2025-11-12 17:42:29 +01:00
vcoppe
f0f7d5ea2f New translations time.mdx (French) 2025-11-12 17:42:28 +01:00
vcoppe
30af13cf67 New translations time.mdx (Romanian) 2025-11-12 17:42:27 +01:00
vcoppe
45e8a2ffd8 New translations scissors.mdx (Serbian (Latin)) 2025-11-12 17:42:26 +01:00
vcoppe
b41bb21fba New translations scissors.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:42:25 +01:00
vcoppe
e828d82a5a New translations scissors.mdx (Latvian) 2025-11-12 17:42:23 +01:00
vcoppe
563ac5e44c New translations scissors.mdx (Thai) 2025-11-12 17:42:22 +01:00
vcoppe
fbc46cf7bc New translations scissors.mdx (Indonesian) 2025-11-12 17:42:21 +01:00
vcoppe
5efc0dc11b New translations scissors.mdx (Portuguese, Brazilian) 2025-11-12 17:42:20 +01:00
vcoppe
d16ce01efd New translations scissors.mdx (Vietnamese) 2025-11-12 17:42:18 +01:00
vcoppe
8ff0ca1855 New translations scissors.mdx (Chinese Simplified) 2025-11-12 17:42:17 +01:00
vcoppe
6e49714e80 New translations scissors.mdx (Ukrainian) 2025-11-12 17:42:16 +01:00
vcoppe
f402ef278f New translations scissors.mdx (Turkish) 2025-11-12 17:42:15 +01:00
vcoppe
4fb32bc11b New translations scissors.mdx (Swedish) 2025-11-12 17:42:13 +01:00
vcoppe
684b29d479 New translations scissors.mdx (Russian) 2025-11-12 17:42:12 +01:00
vcoppe
5b2f253f10 New translations scissors.mdx (Portuguese) 2025-11-12 17:42:11 +01:00
vcoppe
c88ad2c97b New translations scissors.mdx (Polish) 2025-11-12 17:42:10 +01:00
vcoppe
3e0f670e02 New translations scissors.mdx (Norwegian) 2025-11-12 17:42:08 +01:00
vcoppe
737e8167b4 New translations scissors.mdx (Dutch) 2025-11-12 17:42:07 +01:00
vcoppe
23be635323 New translations scissors.mdx (Lithuanian) 2025-11-12 17:42:06 +01:00
vcoppe
8d7d63a7fa New translations scissors.mdx (Korean) 2025-11-12 17:42:05 +01:00
vcoppe
8ad6c77c92 New translations scissors.mdx (Italian) 2025-11-12 17:42:03 +01:00
vcoppe
7d1f862185 New translations scissors.mdx (Hungarian) 2025-11-12 17:42:02 +01:00
vcoppe
2d9561de38 New translations scissors.mdx (Hebrew) 2025-11-12 17:42:01 +01:00
vcoppe
1c09d67846 New translations scissors.mdx (Finnish) 2025-11-12 17:42:00 +01:00
vcoppe
5f5775d201 New translations scissors.mdx (Basque) 2025-11-12 17:41:59 +01:00
vcoppe
db64d97ef1 New translations scissors.mdx (Greek) 2025-11-12 17:41:57 +01:00
vcoppe
19b0f7e572 New translations scissors.mdx (German) 2025-11-12 17:41:56 +01:00
vcoppe
2fb0ccc752 New translations scissors.mdx (Danish) 2025-11-12 17:41:54 +01:00
vcoppe
e6f1abad32 New translations scissors.mdx (Czech) 2025-11-12 17:41:53 +01:00
vcoppe
816f36de50 New translations scissors.mdx (Catalan) 2025-11-12 17:41:52 +01:00
vcoppe
193056531c New translations scissors.mdx (Belarusian) 2025-11-12 17:41:51 +01:00
vcoppe
ce356ce3ff New translations scissors.mdx (Spanish) 2025-11-12 17:41:50 +01:00
vcoppe
334b6d7588 New translations scissors.mdx (French) 2025-11-12 17:41:48 +01:00
vcoppe
2c38082cec New translations scissors.mdx (Romanian) 2025-11-12 17:41:47 +01:00
vcoppe
5dfc5d35ea New translations routing.mdx (Serbian (Latin)) 2025-11-12 17:41:46 +01:00
vcoppe
9d6782e4eb New translations routing.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:41:45 +01:00
vcoppe
81cf903e5f New translations routing.mdx (Latvian) 2025-11-12 17:41:43 +01:00
vcoppe
3d02a377ba New translations routing.mdx (Thai) 2025-11-12 17:41:42 +01:00
vcoppe
e80659cf38 New translations routing.mdx (Indonesian) 2025-11-12 17:41:41 +01:00
vcoppe
3c0eeba130 New translations routing.mdx (Portuguese, Brazilian) 2025-11-12 17:41:39 +01:00
vcoppe
6911bc624f New translations routing.mdx (Vietnamese) 2025-11-12 17:41:38 +01:00
vcoppe
d732745e2d New translations routing.mdx (Chinese Simplified) 2025-11-12 17:41:37 +01:00
vcoppe
9cd0fb8420 New translations routing.mdx (Ukrainian) 2025-11-12 17:41:35 +01:00
vcoppe
e3ed704119 New translations routing.mdx (Turkish) 2025-11-12 17:41:34 +01:00
vcoppe
7ff575de97 New translations routing.mdx (Swedish) 2025-11-12 17:41:33 +01:00
vcoppe
c0083be420 New translations routing.mdx (Russian) 2025-11-12 17:41:32 +01:00
vcoppe
4272a25ffd New translations routing.mdx (Portuguese) 2025-11-12 17:41:30 +01:00
vcoppe
3e39e93999 New translations routing.mdx (Polish) 2025-11-12 17:41:29 +01:00
vcoppe
b0dce71d1e New translations routing.mdx (Norwegian) 2025-11-12 17:41:28 +01:00
vcoppe
fd66246ec5 New translations routing.mdx (Dutch) 2025-11-12 17:41:26 +01:00
vcoppe
0dc1598eca New translations routing.mdx (Lithuanian) 2025-11-12 17:41:25 +01:00
vcoppe
d5108acc99 New translations routing.mdx (Korean) 2025-11-12 17:41:24 +01:00
vcoppe
d87facb618 New translations routing.mdx (Italian) 2025-11-12 17:41:22 +01:00
vcoppe
e4c4b3f07d New translations routing.mdx (Hungarian) 2025-11-12 17:41:21 +01:00
vcoppe
70c4a52321 New translations routing.mdx (Hebrew) 2025-11-12 17:41:20 +01:00
vcoppe
7102ef0d26 New translations routing.mdx (Finnish) 2025-11-12 17:41:18 +01:00
vcoppe
e91383d558 New translations routing.mdx (Basque) 2025-11-12 17:41:17 +01:00
vcoppe
a4708a5fce New translations routing.mdx (Greek) 2025-11-12 17:41:16 +01:00
vcoppe
3d06200b71 New translations routing.mdx (German) 2025-11-12 17:41:14 +01:00
vcoppe
4d173d7d76 New translations routing.mdx (Danish) 2025-11-12 17:41:13 +01:00
vcoppe
1fb2f03835 New translations routing.mdx (Catalan) 2025-11-12 17:41:11 +01:00
vcoppe
15d010d895 New translations routing.mdx (Belarusian) 2025-11-12 17:41:10 +01:00
vcoppe
7367b19515 New translations routing.mdx (Spanish) 2025-11-12 17:41:09 +01:00
vcoppe
9202ee5289 New translations routing.mdx (French) 2025-11-12 17:41:07 +01:00
vcoppe
a4bb04786d New translations routing.mdx (Romanian) 2025-11-12 17:41:06 +01:00
vcoppe
94591fdd4b New translations poi.mdx (Serbian (Latin)) 2025-11-12 17:41:04 +01:00
vcoppe
3df7b27d9b New translations poi.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:41:03 +01:00
vcoppe
bbf1bcbf58 New translations poi.mdx (Latvian) 2025-11-12 17:41:02 +01:00
vcoppe
383c3ec00c New translations poi.mdx (Thai) 2025-11-12 17:41:00 +01:00
vcoppe
e328cdc21a New translations poi.mdx (Indonesian) 2025-11-12 17:40:59 +01:00
vcoppe
c80b48e9c8 New translations poi.mdx (Portuguese, Brazilian) 2025-11-12 17:40:58 +01:00
vcoppe
4128ae3aed New translations poi.mdx (Vietnamese) 2025-11-12 17:40:57 +01:00
vcoppe
e34f5b01bb New translations poi.mdx (Chinese Simplified) 2025-11-12 17:40:55 +01:00
vcoppe
ef80df69ca New translations poi.mdx (Ukrainian) 2025-11-12 17:40:54 +01:00
vcoppe
4a34d6c599 New translations poi.mdx (Turkish) 2025-11-12 17:40:53 +01:00
vcoppe
55bb191268 New translations poi.mdx (Swedish) 2025-11-12 17:40:52 +01:00
vcoppe
36ae02a44d New translations poi.mdx (Russian) 2025-11-12 17:40:51 +01:00
vcoppe
43f282dded New translations poi.mdx (Portuguese) 2025-11-12 17:40:49 +01:00
vcoppe
639418692e New translations poi.mdx (Polish) 2025-11-12 17:40:48 +01:00
vcoppe
12e3a0cddd New translations poi.mdx (Norwegian) 2025-11-12 17:40:47 +01:00
vcoppe
c6a6e9511f New translations poi.mdx (Dutch) 2025-11-12 17:40:45 +01:00
vcoppe
5f34726b22 New translations poi.mdx (Lithuanian) 2025-11-12 17:40:44 +01:00
vcoppe
f9f16ec609 New translations poi.mdx (Korean) 2025-11-12 17:40:42 +01:00
vcoppe
ae7f14a975 New translations poi.mdx (Italian) 2025-11-12 17:40:41 +01:00
vcoppe
1a2652855a New translations poi.mdx (Hungarian) 2025-11-12 17:40:40 +01:00
vcoppe
bfb52060e7 New translations poi.mdx (Hebrew) 2025-11-12 17:40:39 +01:00
vcoppe
371998cef4 New translations poi.mdx (Finnish) 2025-11-12 17:40:38 +01:00
vcoppe
af379947b8 New translations poi.mdx (Basque) 2025-11-12 17:40:36 +01:00
vcoppe
09bddc0958 New translations poi.mdx (Greek) 2025-11-12 17:40:35 +01:00
vcoppe
b4e971596d New translations poi.mdx (German) 2025-11-12 17:40:34 +01:00
vcoppe
57bc7251cf New translations poi.mdx (Danish) 2025-11-12 17:40:32 +01:00
vcoppe
576142a25a New translations poi.mdx (Czech) 2025-11-12 17:40:31 +01:00
vcoppe
76a7f168af New translations poi.mdx (Catalan) 2025-11-12 17:40:30 +01:00
vcoppe
5b1ae587c0 New translations poi.mdx (Belarusian) 2025-11-12 17:40:29 +01:00
vcoppe
c9cdcaeb94 New translations poi.mdx (Spanish) 2025-11-12 17:40:27 +01:00
vcoppe
39581801c6 New translations poi.mdx (French) 2025-11-12 17:40:26 +01:00
vcoppe
3983d39f0c New translations poi.mdx (Romanian) 2025-11-12 17:40:25 +01:00
vcoppe
4fc41816ca New translations minify.mdx (Serbian (Latin)) 2025-11-12 17:40:24 +01:00
vcoppe
d102ff4de7 New translations minify.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:40:22 +01:00
vcoppe
adf34ee85a New translations minify.mdx (Latvian) 2025-11-12 17:40:21 +01:00
vcoppe
870f1c11da New translations minify.mdx (Thai) 2025-11-12 17:40:20 +01:00
vcoppe
11aedd1274 New translations minify.mdx (Indonesian) 2025-11-12 17:40:18 +01:00
vcoppe
ef04c874f0 New translations minify.mdx (Portuguese, Brazilian) 2025-11-12 17:40:17 +01:00
vcoppe
d478f74252 New translations minify.mdx (Vietnamese) 2025-11-12 17:40:16 +01:00
vcoppe
3d140ae9c2 New translations minify.mdx (Chinese Simplified) 2025-11-12 17:40:15 +01:00
vcoppe
9dc652322f New translations minify.mdx (Ukrainian) 2025-11-12 17:40:13 +01:00
vcoppe
0ae7f59444 New translations minify.mdx (Turkish) 2025-11-12 17:40:12 +01:00
vcoppe
e215d473c6 New translations minify.mdx (Swedish) 2025-11-12 17:40:11 +01:00
vcoppe
96cbd24d4f New translations minify.mdx (Russian) 2025-11-12 17:40:10 +01:00
vcoppe
665f12785d New translations minify.mdx (Portuguese) 2025-11-12 17:40:08 +01:00
vcoppe
7a933ff297 New translations minify.mdx (Polish) 2025-11-12 17:40:07 +01:00
vcoppe
9f59cce7c9 New translations minify.mdx (Norwegian) 2025-11-12 17:40:05 +01:00
vcoppe
2c04528c12 New translations minify.mdx (Dutch) 2025-11-12 17:40:04 +01:00
vcoppe
aad22e2698 New translations minify.mdx (Lithuanian) 2025-11-12 17:40:03 +01:00
vcoppe
3714deac89 New translations minify.mdx (Korean) 2025-11-12 17:40:02 +01:00
vcoppe
37c4c9538b New translations minify.mdx (Italian) 2025-11-12 17:40:00 +01:00
vcoppe
5ca5473c1b New translations minify.mdx (Hungarian) 2025-11-12 17:39:59 +01:00
vcoppe
4b5bc7b2d7 New translations minify.mdx (Hebrew) 2025-11-12 17:39:57 +01:00
vcoppe
72aa9fe46f New translations minify.mdx (Finnish) 2025-11-12 17:39:56 +01:00
vcoppe
ce08c5165b New translations minify.mdx (Basque) 2025-11-12 17:39:55 +01:00
vcoppe
61e6b9476d New translations minify.mdx (Greek) 2025-11-12 17:39:54 +01:00
vcoppe
4466787c94 New translations minify.mdx (German) 2025-11-12 17:39:53 +01:00
vcoppe
0df0e891cb New translations minify.mdx (Danish) 2025-11-12 17:39:51 +01:00
vcoppe
89d608b2ac New translations minify.mdx (Czech) 2025-11-12 17:39:50 +01:00
vcoppe
5b0c09f75d New translations minify.mdx (Catalan) 2025-11-12 17:39:49 +01:00
vcoppe
a9e114388f New translations minify.mdx (Belarusian) 2025-11-12 17:39:47 +01:00
vcoppe
31c428ccbc New translations minify.mdx (Spanish) 2025-11-12 17:39:46 +01:00
vcoppe
9d4d70ee76 New translations minify.mdx (French) 2025-11-12 17:39:45 +01:00
vcoppe
5258327694 New translations minify.mdx (Romanian) 2025-11-12 17:39:44 +01:00
vcoppe
e80875e3b0 New translations merge.mdx (Serbian (Latin)) 2025-11-12 17:39:42 +01:00
vcoppe
cb42dcf2c4 New translations merge.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:39:41 +01:00
vcoppe
fe9f3f0a55 New translations merge.mdx (Latvian) 2025-11-12 17:39:40 +01:00
vcoppe
712d193f7c New translations merge.mdx (Thai) 2025-11-12 17:39:39 +01:00
vcoppe
c546185dda New translations merge.mdx (Indonesian) 2025-11-12 17:39:37 +01:00
vcoppe
5945a1c063 New translations merge.mdx (Portuguese, Brazilian) 2025-11-12 17:39:36 +01:00
vcoppe
79a17b4720 New translations merge.mdx (Vietnamese) 2025-11-12 17:39:35 +01:00
vcoppe
0c69eb2910 New translations merge.mdx (Chinese Simplified) 2025-11-12 17:39:34 +01:00
vcoppe
9f5b865949 New translations merge.mdx (Ukrainian) 2025-11-12 17:39:33 +01:00
vcoppe
fccbe1c700 New translations merge.mdx (Turkish) 2025-11-12 17:39:31 +01:00
vcoppe
a67f503b15 New translations merge.mdx (Swedish) 2025-11-12 17:39:30 +01:00
vcoppe
b9d37a87d3 New translations merge.mdx (Russian) 2025-11-12 17:39:29 +01:00
vcoppe
103580954b New translations merge.mdx (Portuguese) 2025-11-12 17:39:28 +01:00
vcoppe
a46a5cb723 New translations merge.mdx (Polish) 2025-11-12 17:39:27 +01:00
vcoppe
7c9bb0786f New translations merge.mdx (Norwegian) 2025-11-12 17:39:25 +01:00
vcoppe
a10e365fe3 New translations merge.mdx (Dutch) 2025-11-12 17:39:24 +01:00
vcoppe
bb11d417aa New translations merge.mdx (Lithuanian) 2025-11-12 17:39:23 +01:00
vcoppe
1014c5b8fc New translations merge.mdx (Korean) 2025-11-12 17:39:22 +01:00
vcoppe
0f52beeda0 New translations merge.mdx (Italian) 2025-11-12 17:39:21 +01:00
vcoppe
b34c87a4a8 New translations merge.mdx (Hungarian) 2025-11-12 17:39:19 +01:00
vcoppe
c4ea8c8902 New translations merge.mdx (Hebrew) 2025-11-12 17:39:18 +01:00
vcoppe
67f79e447e New translations merge.mdx (Finnish) 2025-11-12 17:39:17 +01:00
vcoppe
c55e2b5223 New translations merge.mdx (Basque) 2025-11-12 17:39:16 +01:00
vcoppe
58020a2ddd New translations merge.mdx (Greek) 2025-11-12 17:39:15 +01:00
vcoppe
263fb75e40 New translations merge.mdx (German) 2025-11-12 17:39:13 +01:00
vcoppe
e2253b07f8 New translations merge.mdx (Danish) 2025-11-12 17:39:12 +01:00
vcoppe
fd9a23a2b4 New translations merge.mdx (Czech) 2025-11-12 17:39:11 +01:00
vcoppe
1f175e3693 New translations merge.mdx (Catalan) 2025-11-12 17:39:09 +01:00
vcoppe
cb36ceff9e New translations merge.mdx (Belarusian) 2025-11-12 17:39:08 +01:00
vcoppe
3634071686 New translations merge.mdx (Spanish) 2025-11-12 17:39:07 +01:00
vcoppe
5e09d7cafd New translations merge.mdx (French) 2025-11-12 17:39:06 +01:00
vcoppe
9fc574a1da New translations merge.mdx (Romanian) 2025-11-12 17:39:04 +01:00
vcoppe
f57a3770e6 New translations extract.mdx (Serbian (Latin)) 2025-11-12 17:39:03 +01:00
vcoppe
f3de70d69e New translations extract.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:39:02 +01:00
vcoppe
5bf5ebc279 New translations extract.mdx (Latvian) 2025-11-12 17:39:01 +01:00
vcoppe
0ffbf02ba0 New translations extract.mdx (Thai) 2025-11-12 17:38:59 +01:00
vcoppe
73ffaaecd7 New translations extract.mdx (Indonesian) 2025-11-12 17:38:58 +01:00
vcoppe
b68e5f8b03 New translations extract.mdx (Portuguese, Brazilian) 2025-11-12 17:38:56 +01:00
vcoppe
79586b2d3f New translations extract.mdx (Vietnamese) 2025-11-12 17:38:55 +01:00
vcoppe
5307e9e4a6 New translations extract.mdx (Chinese Simplified) 2025-11-12 17:38:54 +01:00
vcoppe
900225fb8d New translations extract.mdx (Ukrainian) 2025-11-12 17:38:53 +01:00
vcoppe
0d2993ea16 New translations extract.mdx (Turkish) 2025-11-12 17:38:52 +01:00
vcoppe
019857bc61 New translations extract.mdx (Swedish) 2025-11-12 17:38:50 +01:00
vcoppe
aec642d6f3 New translations extract.mdx (Russian) 2025-11-12 17:38:49 +01:00
vcoppe
0ce25946d4 New translations extract.mdx (Portuguese) 2025-11-12 17:38:48 +01:00
vcoppe
d4d897b53a New translations extract.mdx (Polish) 2025-11-12 17:38:47 +01:00
vcoppe
e7967c441c New translations extract.mdx (Norwegian) 2025-11-12 17:38:45 +01:00
vcoppe
9122e8bf0e New translations extract.mdx (Dutch) 2025-11-12 17:38:44 +01:00
vcoppe
3ac4e30a5f New translations extract.mdx (Lithuanian) 2025-11-12 17:38:43 +01:00
vcoppe
668b77c91f New translations extract.mdx (Korean) 2025-11-12 17:38:42 +01:00
vcoppe
5dae658d4b New translations extract.mdx (Italian) 2025-11-12 17:38:41 +01:00
vcoppe
adc132609b New translations extract.mdx (Hungarian) 2025-11-12 17:38:39 +01:00
vcoppe
c9853a4058 New translations extract.mdx (Hebrew) 2025-11-12 17:38:38 +01:00
vcoppe
336ad925aa New translations extract.mdx (Finnish) 2025-11-12 17:38:37 +01:00
vcoppe
624dd408c1 New translations extract.mdx (Basque) 2025-11-12 17:38:36 +01:00
vcoppe
aa7ffbe88d New translations extract.mdx (Greek) 2025-11-12 17:38:35 +01:00
vcoppe
bb6ae3ec00 New translations extract.mdx (German) 2025-11-12 17:38:33 +01:00
vcoppe
e568f77de3 New translations extract.mdx (Danish) 2025-11-12 17:38:32 +01:00
vcoppe
ec23dfba68 New translations extract.mdx (Czech) 2025-11-12 17:38:31 +01:00
vcoppe
817a67a118 New translations extract.mdx (Catalan) 2025-11-12 17:38:30 +01:00
vcoppe
bf5aa1321c New translations extract.mdx (Belarusian) 2025-11-12 17:38:29 +01:00
vcoppe
85d1be85a3 New translations extract.mdx (Spanish) 2025-11-12 17:38:27 +01:00
vcoppe
ecc15cb462 New translations extract.mdx (French) 2025-11-12 17:38:26 +01:00
vcoppe
4bbf813617 New translations extract.mdx (Romanian) 2025-11-12 17:38:25 +01:00
vcoppe
28fd8b104c New translations clean.mdx (Serbian (Latin)) 2025-11-12 17:38:24 +01:00
vcoppe
578c3732b3 New translations clean.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:38:22 +01:00
vcoppe
7568f1581f New translations clean.mdx (Latvian) 2025-11-12 17:38:21 +01:00
vcoppe
0d07ef8eab New translations clean.mdx (Thai) 2025-11-12 17:38:20 +01:00
vcoppe
301bbf4206 New translations clean.mdx (Indonesian) 2025-11-12 17:38:19 +01:00
vcoppe
14057bf3b4 New translations clean.mdx (Portuguese, Brazilian) 2025-11-12 17:38:17 +01:00
vcoppe
01527c1dba New translations clean.mdx (Vietnamese) 2025-11-12 17:38:16 +01:00
vcoppe
3350b7e8e8 New translations clean.mdx (Chinese Simplified) 2025-11-12 17:38:15 +01:00
vcoppe
673d6d776e New translations clean.mdx (Ukrainian) 2025-11-12 17:38:13 +01:00
vcoppe
4169f3880c New translations clean.mdx (Turkish) 2025-11-12 17:38:12 +01:00
vcoppe
a21b17be89 New translations clean.mdx (Swedish) 2025-11-12 17:38:11 +01:00
vcoppe
dad9f4f790 New translations clean.mdx (Russian) 2025-11-12 17:38:10 +01:00
vcoppe
f6b5fa83af New translations clean.mdx (Portuguese) 2025-11-12 17:38:09 +01:00
vcoppe
a3bcef09d4 New translations clean.mdx (Polish) 2025-11-12 17:38:07 +01:00
vcoppe
545fb1368a New translations clean.mdx (Norwegian) 2025-11-12 17:38:06 +01:00
vcoppe
d737131f27 New translations clean.mdx (Dutch) 2025-11-12 17:38:05 +01:00
vcoppe
ead2477400 New translations clean.mdx (Lithuanian) 2025-11-12 17:38:04 +01:00
vcoppe
aa7cc66a81 New translations clean.mdx (Korean) 2025-11-12 17:38:02 +01:00
vcoppe
d82882c98a New translations clean.mdx (Italian) 2025-11-12 17:38:01 +01:00
vcoppe
4e0584177f New translations clean.mdx (Hungarian) 2025-11-12 17:38:00 +01:00
vcoppe
bca62140d0 New translations clean.mdx (Hebrew) 2025-11-12 17:37:59 +01:00
vcoppe
1e80cfe7b3 New translations clean.mdx (Finnish) 2025-11-12 17:37:57 +01:00
vcoppe
a27863e67a New translations clean.mdx (Basque) 2025-11-12 17:37:56 +01:00
vcoppe
d491eeb314 New translations clean.mdx (Greek) 2025-11-12 17:37:55 +01:00
vcoppe
c943d0c796 New translations clean.mdx (German) 2025-11-12 17:37:54 +01:00
vcoppe
bf749e02e2 New translations clean.mdx (Danish) 2025-11-12 17:37:53 +01:00
vcoppe
ea7a17fbc6 New translations clean.mdx (Czech) 2025-11-12 17:37:51 +01:00
vcoppe
7da23271b9 New translations clean.mdx (Catalan) 2025-11-12 17:37:50 +01:00
vcoppe
49204ac5b0 New translations clean.mdx (Belarusian) 2025-11-12 17:37:49 +01:00
vcoppe
d38fb3c30b New translations clean.mdx (Spanish) 2025-11-12 17:37:47 +01:00
vcoppe
9a2f75d33e New translations clean.mdx (French) 2025-11-12 17:37:46 +01:00
vcoppe
ce64e201ce New translations clean.mdx (Romanian) 2025-11-12 17:37:45 +01:00
vcoppe
49ac3859a6 New translations view.mdx (Serbian (Latin)) 2025-11-12 17:37:31 +01:00
vcoppe
5e63f53b4e New translations view.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:37:30 +01:00
vcoppe
a24a1d55fa New translations view.mdx (Latvian) 2025-11-12 17:37:28 +01:00
vcoppe
7e69310202 New translations view.mdx (Thai) 2025-11-12 17:37:27 +01:00
vcoppe
9d65bd006d New translations view.mdx (Indonesian) 2025-11-12 17:37:26 +01:00
vcoppe
552cb854e6 New translations view.mdx (Portuguese, Brazilian) 2025-11-12 17:37:25 +01:00
vcoppe
7cc9f23792 New translations view.mdx (Vietnamese) 2025-11-12 17:37:23 +01:00
vcoppe
8b3a852508 New translations view.mdx (Chinese Simplified) 2025-11-12 17:37:22 +01:00
vcoppe
1bbbbffd8d New translations view.mdx (Ukrainian) 2025-11-12 17:37:21 +01:00
vcoppe
52906e8041 New translations view.mdx (Turkish) 2025-11-12 17:37:20 +01:00
vcoppe
6e402f3c97 New translations view.mdx (Swedish) 2025-11-12 17:37:19 +01:00
vcoppe
549d8debb8 New translations view.mdx (Russian) 2025-11-12 17:37:17 +01:00
vcoppe
52027b028a New translations view.mdx (Portuguese) 2025-11-12 17:37:16 +01:00
vcoppe
8d922e55d9 New translations view.mdx (Polish) 2025-11-12 17:37:15 +01:00
vcoppe
5eea175b76 New translations view.mdx (Norwegian) 2025-11-12 17:37:14 +01:00
vcoppe
be843d056e New translations view.mdx (Dutch) 2025-11-12 17:37:12 +01:00
vcoppe
7a58268c25 New translations view.mdx (Lithuanian) 2025-11-12 17:37:11 +01:00
vcoppe
eb23baed5d New translations view.mdx (Korean) 2025-11-12 17:37:09 +01:00
vcoppe
afb699bc12 New translations view.mdx (Italian) 2025-11-12 17:37:08 +01:00
vcoppe
3112f4158b New translations view.mdx (Hungarian) 2025-11-12 17:37:07 +01:00
vcoppe
2cce3eb452 New translations view.mdx (Hebrew) 2025-11-12 17:37:06 +01:00
vcoppe
bf58de7c1f New translations view.mdx (Finnish) 2025-11-12 17:37:05 +01:00
vcoppe
fa797f219f New translations view.mdx (Basque) 2025-11-12 17:37:03 +01:00
vcoppe
f6f859a343 New translations view.mdx (Greek) 2025-11-12 17:37:02 +01:00
vcoppe
baf43cefe9 New translations view.mdx (German) 2025-11-12 17:37:01 +01:00
vcoppe
7c4e3cfe32 New translations view.mdx (Danish) 2025-11-12 17:37:00 +01:00
vcoppe
ed6b41823d New translations view.mdx (Czech) 2025-11-12 17:36:59 +01:00
vcoppe
32f141aa2b New translations view.mdx (Catalan) 2025-11-12 17:36:57 +01:00
vcoppe
9f76ece5cf New translations view.mdx (Belarusian) 2025-11-12 17:36:56 +01:00
vcoppe
ba08346083 New translations view.mdx (Spanish) 2025-11-12 17:36:55 +01:00
vcoppe
fe2d418b3a New translations view.mdx (French) 2025-11-12 17:36:54 +01:00
vcoppe
e4e6e0f4ad New translations view.mdx (Romanian) 2025-11-12 17:36:52 +01:00
vcoppe
cb2214ef9b New translations settings.mdx (Serbian (Latin)) 2025-11-12 17:36:51 +01:00
vcoppe
ca1289460d New translations settings.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:36:50 +01:00
vcoppe
0540b9eb12 New translations settings.mdx (Latvian) 2025-11-12 17:36:48 +01:00
vcoppe
0a6cf92f54 New translations settings.mdx (Thai) 2025-11-12 17:36:47 +01:00
vcoppe
835ff9ec19 New translations settings.mdx (Indonesian) 2025-11-12 17:36:46 +01:00
vcoppe
4ba0f4bba3 New translations settings.mdx (Portuguese, Brazilian) 2025-11-12 17:36:45 +01:00
vcoppe
654769b67b New translations settings.mdx (Vietnamese) 2025-11-12 17:36:43 +01:00
vcoppe
4d64ae5944 New translations settings.mdx (Chinese Simplified) 2025-11-12 17:36:41 +01:00
vcoppe
c6f1890055 New translations settings.mdx (Ukrainian) 2025-11-12 17:36:40 +01:00
vcoppe
55f4a6f021 New translations settings.mdx (Turkish) 2025-11-12 17:36:39 +01:00
vcoppe
f8c8b4d368 New translations settings.mdx (Swedish) 2025-11-12 17:36:38 +01:00
vcoppe
be5b248f21 New translations settings.mdx (Russian) 2025-11-12 17:36:37 +01:00
vcoppe
6a11f8bd41 New translations settings.mdx (Portuguese) 2025-11-12 17:36:35 +01:00
vcoppe
2cc6d5ad03 New translations settings.mdx (Polish) 2025-11-12 17:36:34 +01:00
vcoppe
a21c80f843 New translations settings.mdx (Norwegian) 2025-11-12 17:36:33 +01:00
vcoppe
90060a36c6 New translations settings.mdx (Dutch) 2025-11-12 17:36:32 +01:00
vcoppe
ac5c2372fa New translations settings.mdx (Lithuanian) 2025-11-12 17:36:31 +01:00
vcoppe
f6efcd160f New translations settings.mdx (Korean) 2025-11-12 17:36:29 +01:00
vcoppe
83ec2b97d6 New translations settings.mdx (Italian) 2025-11-12 17:36:28 +01:00
vcoppe
3220550633 New translations settings.mdx (Hungarian) 2025-11-12 17:36:26 +01:00
vcoppe
bafafc281a New translations settings.mdx (Hebrew) 2025-11-12 17:36:25 +01:00
vcoppe
b58a0d22c6 New translations settings.mdx (Finnish) 2025-11-12 17:36:24 +01:00
vcoppe
55c25efca7 New translations settings.mdx (Basque) 2025-11-12 17:36:23 +01:00
vcoppe
967a5bf612 New translations settings.mdx (Greek) 2025-11-12 17:36:21 +01:00
vcoppe
fcb762ee8f New translations settings.mdx (German) 2025-11-12 17:36:20 +01:00
vcoppe
38a941e986 New translations settings.mdx (Danish) 2025-11-12 17:36:18 +01:00
vcoppe
3544e1bac3 New translations settings.mdx (Czech) 2025-11-12 17:36:17 +01:00
vcoppe
9c81714568 New translations settings.mdx (Catalan) 2025-11-12 17:36:16 +01:00
vcoppe
dff93c491c New translations settings.mdx (Belarusian) 2025-11-12 17:36:14 +01:00
vcoppe
0b7ffdc45a New translations settings.mdx (Spanish) 2025-11-12 17:36:13 +01:00
vcoppe
71cfc65f68 New translations settings.mdx (French) 2025-11-12 17:36:12 +01:00
vcoppe
005548bdd0 New translations settings.mdx (Romanian) 2025-11-12 17:36:11 +01:00
vcoppe
aa285d8409 New translations file.mdx (Serbian (Latin)) 2025-11-12 17:36:09 +01:00
vcoppe
a801166692 New translations file.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:36:08 +01:00
vcoppe
8259c57fad New translations file.mdx (Latvian) 2025-11-12 17:36:06 +01:00
vcoppe
0b47fadef7 New translations file.mdx (Thai) 2025-11-12 17:36:05 +01:00
vcoppe
4453001b30 New translations file.mdx (Indonesian) 2025-11-12 17:36:04 +01:00
vcoppe
58999c28fe New translations file.mdx (Portuguese, Brazilian) 2025-11-12 17:36:03 +01:00
vcoppe
74d06e5aad New translations file.mdx (Vietnamese) 2025-11-12 17:36:01 +01:00
vcoppe
662c8f5ca2 New translations file.mdx (Chinese Simplified) 2025-11-12 17:36:00 +01:00
vcoppe
b5b5f82464 New translations file.mdx (Ukrainian) 2025-11-12 17:35:59 +01:00
vcoppe
7593ec5587 New translations file.mdx (Turkish) 2025-11-12 17:35:58 +01:00
vcoppe
94ac0ef90c New translations file.mdx (Swedish) 2025-11-12 17:35:56 +01:00
vcoppe
36093288f7 New translations file.mdx (Russian) 2025-11-12 17:35:55 +01:00
vcoppe
d226b3c3c1 New translations file.mdx (Portuguese) 2025-11-12 17:35:54 +01:00
vcoppe
13215e5c16 New translations file.mdx (Polish) 2025-11-12 17:35:53 +01:00
vcoppe
f6c6eb441f New translations file.mdx (Norwegian) 2025-11-12 17:35:51 +01:00
vcoppe
e883ce7c43 New translations file.mdx (Dutch) 2025-11-12 17:35:50 +01:00
vcoppe
4836a99fcd New translations file.mdx (Lithuanian) 2025-11-12 17:35:49 +01:00
vcoppe
a7075067bf New translations file.mdx (Korean) 2025-11-12 17:35:48 +01:00
vcoppe
c8edea25ae New translations file.mdx (Italian) 2025-11-12 17:35:46 +01:00
vcoppe
07731fc2de New translations file.mdx (Hungarian) 2025-11-12 17:35:45 +01:00
vcoppe
8c436d5c0e New translations file.mdx (Hebrew) 2025-11-12 17:35:44 +01:00
vcoppe
16a2b1979e New translations file.mdx (Finnish) 2025-11-12 17:35:43 +01:00
vcoppe
7753613cf4 New translations file.mdx (Basque) 2025-11-12 17:35:42 +01:00
vcoppe
d156a6ffee New translations file.mdx (Greek) 2025-11-12 17:35:41 +01:00
vcoppe
f54b614ece New translations file.mdx (German) 2025-11-12 17:35:39 +01:00
vcoppe
24ed769eac New translations file.mdx (Danish) 2025-11-12 17:35:38 +01:00
vcoppe
ef8387f7c7 New translations file.mdx (Czech) 2025-11-12 17:35:37 +01:00
vcoppe
c76c37c874 New translations file.mdx (Catalan) 2025-11-12 17:35:36 +01:00
vcoppe
bf41cb70f8 New translations file.mdx (Belarusian) 2025-11-12 17:35:34 +01:00
vcoppe
f7e759c69f New translations file.mdx (Spanish) 2025-11-12 17:35:33 +01:00
vcoppe
376360caac New translations file.mdx (French) 2025-11-12 17:35:32 +01:00
vcoppe
66fa02c90a New translations file.mdx (Romanian) 2025-11-12 17:35:31 +01:00
vcoppe
26cc2e3c9e New translations edit.mdx (Serbian (Latin)) 2025-11-12 17:35:29 +01:00
vcoppe
09ceb1b5ce New translations edit.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:35:28 +01:00
vcoppe
1204e971c2 New translations edit.mdx (Latvian) 2025-11-12 17:35:27 +01:00
vcoppe
9833df2110 New translations edit.mdx (Thai) 2025-11-12 17:35:26 +01:00
vcoppe
7843170978 New translations edit.mdx (Indonesian) 2025-11-12 17:35:25 +01:00
vcoppe
93c1535b76 New translations edit.mdx (Portuguese, Brazilian) 2025-11-12 17:35:23 +01:00
vcoppe
c3c6e2e757 New translations edit.mdx (Vietnamese) 2025-11-12 17:35:22 +01:00
vcoppe
2d4b3514bc New translations edit.mdx (Chinese Simplified) 2025-11-12 17:35:21 +01:00
vcoppe
a27fce18ed New translations edit.mdx (Ukrainian) 2025-11-12 17:35:20 +01:00
vcoppe
1adc1833dc New translations edit.mdx (Turkish) 2025-11-12 17:35:19 +01:00
vcoppe
9a1c721a46 New translations edit.mdx (Swedish) 2025-11-12 17:35:17 +01:00
vcoppe
9bd7d49fe7 New translations edit.mdx (Russian) 2025-11-12 17:35:16 +01:00
vcoppe
e94f128de8 New translations edit.mdx (Portuguese) 2025-11-12 17:35:15 +01:00
vcoppe
9005a58e43 New translations edit.mdx (Polish) 2025-11-12 17:35:14 +01:00
vcoppe
12f07c57f2 New translations edit.mdx (Norwegian) 2025-11-12 17:35:12 +01:00
vcoppe
3c6066f047 New translations edit.mdx (Dutch) 2025-11-12 17:35:11 +01:00
vcoppe
99d39c6d71 New translations edit.mdx (Lithuanian) 2025-11-12 17:35:09 +01:00
vcoppe
24d088ac79 New translations edit.mdx (Korean) 2025-11-12 17:35:08 +01:00
vcoppe
268ddd9aae New translations edit.mdx (Italian) 2025-11-12 17:35:07 +01:00
vcoppe
ace28b2051 New translations edit.mdx (Hungarian) 2025-11-12 17:35:06 +01:00
vcoppe
beb9b8c19c New translations edit.mdx (Hebrew) 2025-11-12 17:35:04 +01:00
vcoppe
9f1c23ea67 New translations edit.mdx (Finnish) 2025-11-12 17:35:03 +01:00
vcoppe
901a41dab1 New translations edit.mdx (Basque) 2025-11-12 17:35:02 +01:00
vcoppe
8777cb3a72 New translations edit.mdx (Greek) 2025-11-12 17:35:01 +01:00
vcoppe
c34a9719d9 New translations edit.mdx (German) 2025-11-12 17:34:59 +01:00
vcoppe
54a3535373 New translations edit.mdx (Danish) 2025-11-12 17:34:58 +01:00
vcoppe
7e2cecc838 New translations edit.mdx (Czech) 2025-11-12 17:34:57 +01:00
vcoppe
33a61c41d4 New translations edit.mdx (Catalan) 2025-11-12 17:34:56 +01:00
vcoppe
e21d5ea4ef New translations edit.mdx (Belarusian) 2025-11-12 17:34:54 +01:00
vcoppe
96c12b38ed New translations edit.mdx (Spanish) 2025-11-12 17:34:53 +01:00
vcoppe
2faa17a608 New translations edit.mdx (French) 2025-11-12 17:34:52 +01:00
vcoppe
5386d3b19f New translations edit.mdx (Romanian) 2025-11-12 17:34:50 +01:00
vcoppe
56b219ed0d New translations map-controls.mdx (Serbian (Latin)) 2025-11-12 17:34:36 +01:00
vcoppe
5bfd82272d New translations map-controls.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:34:35 +01:00
vcoppe
9af0480418 New translations map-controls.mdx (Latvian) 2025-11-12 17:34:33 +01:00
vcoppe
bcbd9535e5 New translations map-controls.mdx (Thai) 2025-11-12 17:34:32 +01:00
vcoppe
e4bf263fe2 New translations map-controls.mdx (Indonesian) 2025-11-12 17:34:31 +01:00
vcoppe
a5da416a54 New translations map-controls.mdx (Portuguese, Brazilian) 2025-11-12 17:34:30 +01:00
vcoppe
1dbc9c968f New translations map-controls.mdx (Vietnamese) 2025-11-12 17:34:29 +01:00
vcoppe
b5b6b293ca New translations map-controls.mdx (Chinese Simplified) 2025-11-12 17:34:27 +01:00
vcoppe
b7df027c05 New translations map-controls.mdx (Ukrainian) 2025-11-12 17:34:26 +01:00
vcoppe
6890c2b182 New translations map-controls.mdx (Turkish) 2025-11-12 17:34:25 +01:00
vcoppe
2f90728b82 New translations map-controls.mdx (Swedish) 2025-11-12 17:34:23 +01:00
vcoppe
d8e445315b New translations map-controls.mdx (Russian) 2025-11-12 17:34:22 +01:00
vcoppe
26d99cc790 New translations map-controls.mdx (Portuguese) 2025-11-12 17:34:21 +01:00
vcoppe
52c273d47f New translations map-controls.mdx (Polish) 2025-11-12 17:34:20 +01:00
vcoppe
9ebca23a96 New translations map-controls.mdx (Norwegian) 2025-11-12 17:34:19 +01:00
vcoppe
0775c745dd New translations map-controls.mdx (Dutch) 2025-11-12 17:34:17 +01:00
vcoppe
f4111a0185 New translations map-controls.mdx (Lithuanian) 2025-11-12 17:34:16 +01:00
vcoppe
6ad88accf4 New translations map-controls.mdx (Korean) 2025-11-12 17:34:15 +01:00
vcoppe
9669b7bd16 New translations map-controls.mdx (Italian) 2025-11-12 17:34:14 +01:00
vcoppe
a37e27a82f New translations map-controls.mdx (Hungarian) 2025-11-12 17:34:13 +01:00
vcoppe
85e347dfe2 New translations map-controls.mdx (Hebrew) 2025-11-12 17:34:11 +01:00
vcoppe
afcaa89cc3 New translations map-controls.mdx (Finnish) 2025-11-12 17:34:10 +01:00
vcoppe
c87c8952c4 New translations map-controls.mdx (Basque) 2025-11-12 17:34:08 +01:00
vcoppe
d08d8dacb4 New translations map-controls.mdx (Greek) 2025-11-12 17:34:07 +01:00
vcoppe
2fbad4fa91 New translations map-controls.mdx (German) 2025-11-12 17:34:06 +01:00
vcoppe
08731d2708 New translations map-controls.mdx (Danish) 2025-11-12 17:34:04 +01:00
vcoppe
a684b7c101 New translations map-controls.mdx (Czech) 2025-11-12 17:34:03 +01:00
vcoppe
6000d8c939 New translations map-controls.mdx (Catalan) 2025-11-12 17:34:02 +01:00
vcoppe
4a30157638 New translations map-controls.mdx (Belarusian) 2025-11-12 17:34:00 +01:00
vcoppe
16fc317543 New translations map-controls.mdx (Spanish) 2025-11-12 17:33:59 +01:00
vcoppe
40e6b801ef New translations map-controls.mdx (French) 2025-11-12 17:33:58 +01:00
vcoppe
e908b4d996 New translations map-controls.mdx (Romanian) 2025-11-12 17:33:56 +01:00
vcoppe
a4230df2be New translations translation.mdx (Serbian (Latin)) 2025-11-12 17:33:42 +01:00
vcoppe
37894a26e7 New translations translation.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:33:41 +01:00
vcoppe
d9ee36076c New translations translation.mdx (Latvian) 2025-11-12 17:33:40 +01:00
vcoppe
f9f07de8ef New translations translation.mdx (Thai) 2025-11-12 17:33:39 +01:00
vcoppe
141560e159 New translations translation.mdx (Indonesian) 2025-11-12 17:33:38 +01:00
vcoppe
f7a9697779 New translations translation.mdx (Portuguese, Brazilian) 2025-11-12 17:33:36 +01:00
vcoppe
e778e3e85f New translations translation.mdx (Vietnamese) 2025-11-12 17:33:35 +01:00
vcoppe
c227b5c37b New translations translation.mdx (Chinese Simplified) 2025-11-12 17:33:34 +01:00
vcoppe
bdf03ab962 New translations translation.mdx (Ukrainian) 2025-11-12 17:33:32 +01:00
vcoppe
07e2cdbac1 New translations translation.mdx (Turkish) 2025-11-12 17:33:31 +01:00
vcoppe
ec0c27c421 New translations translation.mdx (Swedish) 2025-11-12 17:33:30 +01:00
vcoppe
5670d47c5f New translations translation.mdx (Russian) 2025-11-12 17:33:28 +01:00
vcoppe
c8a3bf2d8a New translations translation.mdx (Portuguese) 2025-11-12 17:33:27 +01:00
vcoppe
cc28f877fd New translations translation.mdx (Polish) 2025-11-12 17:33:26 +01:00
vcoppe
39c746068a New translations translation.mdx (Norwegian) 2025-11-12 17:33:25 +01:00
vcoppe
f37ad668be New translations translation.mdx (Dutch) 2025-11-12 17:33:24 +01:00
vcoppe
e87561fb17 New translations translation.mdx (Lithuanian) 2025-11-12 17:33:22 +01:00
vcoppe
96084ca57f New translations translation.mdx (Korean) 2025-11-12 17:33:21 +01:00
vcoppe
186b60dcfe New translations translation.mdx (Italian) 2025-11-12 17:33:20 +01:00
vcoppe
aa10fbaf9f New translations translation.mdx (Hungarian) 2025-11-12 17:33:18 +01:00
vcoppe
2954b353c9 New translations translation.mdx (Hebrew) 2025-11-12 17:33:17 +01:00
vcoppe
63614e5e0c New translations translation.mdx (Finnish) 2025-11-12 17:33:15 +01:00
vcoppe
899ac29ec1 New translations translation.mdx (Basque) 2025-11-12 17:33:14 +01:00
vcoppe
722683fae2 New translations translation.mdx (Greek) 2025-11-12 17:33:13 +01:00
vcoppe
5eb82b5ef9 New translations translation.mdx (German) 2025-11-12 17:33:11 +01:00
vcoppe
89e8f0ba2d New translations translation.mdx (Danish) 2025-11-12 17:33:10 +01:00
vcoppe
f6cfde92c4 New translations translation.mdx (Czech) 2025-11-12 17:33:08 +01:00
vcoppe
35b7063ca8 New translations translation.mdx (Catalan) 2025-11-12 17:33:06 +01:00
vcoppe
23357de4db New translations translation.mdx (Belarusian) 2025-11-12 17:33:05 +01:00
vcoppe
0850db9f0d New translations translation.mdx (Spanish) 2025-11-12 17:33:03 +01:00
vcoppe
0206bb94be New translations translation.mdx (French) 2025-11-12 17:33:02 +01:00
vcoppe
d25bbd216d New translations translation.mdx (Romanian) 2025-11-12 17:33:01 +01:00
vcoppe
aedd584fbc New translations funding.mdx (Serbian (Latin)) 2025-11-12 17:32:46 +01:00
vcoppe
4a68e2c8be New translations funding.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:32:45 +01:00
vcoppe
926fbc7fe7 New translations funding.mdx (Latvian) 2025-11-12 17:32:44 +01:00
vcoppe
7cf4ced17d New translations funding.mdx (Thai) 2025-11-12 17:32:43 +01:00
vcoppe
b415c8b3da New translations funding.mdx (Indonesian) 2025-11-12 17:32:41 +01:00
vcoppe
b58273b98c New translations funding.mdx (Portuguese, Brazilian) 2025-11-12 17:32:40 +01:00
vcoppe
bd5f66b5d5 New translations funding.mdx (Vietnamese) 2025-11-12 17:32:39 +01:00
vcoppe
3abe640640 New translations funding.mdx (Chinese Simplified) 2025-11-12 17:32:37 +01:00
vcoppe
95bc685459 New translations funding.mdx (Ukrainian) 2025-11-12 17:32:36 +01:00
vcoppe
e65c8ceace New translations funding.mdx (Turkish) 2025-11-12 17:32:35 +01:00
vcoppe
d41080c4e3 New translations funding.mdx (Swedish) 2025-11-12 17:32:34 +01:00
vcoppe
95e4169108 New translations funding.mdx (Russian) 2025-11-12 17:32:33 +01:00
vcoppe
09ed3ffd3f New translations funding.mdx (Portuguese) 2025-11-12 17:32:31 +01:00
vcoppe
0cd8ac88d6 New translations funding.mdx (Polish) 2025-11-12 17:32:30 +01:00
vcoppe
99a022f647 New translations funding.mdx (Norwegian) 2025-11-12 17:32:29 +01:00
vcoppe
6aab3d842b New translations funding.mdx (Dutch) 2025-11-12 17:32:28 +01:00
vcoppe
430e004c60 New translations funding.mdx (Lithuanian) 2025-11-12 17:32:27 +01:00
vcoppe
022a2a9f78 New translations funding.mdx (Korean) 2025-11-12 17:32:25 +01:00
vcoppe
821657c447 New translations funding.mdx (Italian) 2025-11-12 17:32:24 +01:00
vcoppe
c03e7517a5 New translations funding.mdx (Hungarian) 2025-11-12 17:32:23 +01:00
vcoppe
a89032b5e4 New translations funding.mdx (Hebrew) 2025-11-12 17:32:21 +01:00
vcoppe
d6c55cf555 New translations funding.mdx (Finnish) 2025-11-12 17:32:20 +01:00
vcoppe
2da2f63503 New translations funding.mdx (Basque) 2025-11-12 17:32:19 +01:00
vcoppe
ab1bb2ee5d New translations funding.mdx (Greek) 2025-11-12 17:32:17 +01:00
vcoppe
326e9a82c9 New translations funding.mdx (German) 2025-11-12 17:32:16 +01:00
vcoppe
fbdba3ef69 New translations funding.mdx (Danish) 2025-11-12 17:32:15 +01:00
vcoppe
312c4e7211 New translations funding.mdx (Czech) 2025-11-12 17:32:13 +01:00
vcoppe
3386e1e6b0 New translations funding.mdx (Catalan) 2025-11-12 17:32:12 +01:00
vcoppe
a11565e94c New translations funding.mdx (Belarusian) 2025-11-12 17:32:10 +01:00
vcoppe
0c0e6d5017 New translations funding.mdx (Spanish) 2025-11-12 17:32:05 +01:00
vcoppe
4c33e88c63 New translations funding.mdx (French) 2025-11-12 17:32:04 +01:00
vcoppe
aa582fb0d8 New translations funding.mdx (Romanian) 2025-11-12 17:32:03 +01:00
vcoppe
c38fdf8ccc New translations gpx.mdx (Serbian (Latin)) 2025-11-12 17:32:01 +01:00
vcoppe
305c2743ba New translations gpx.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:32:00 +01:00
vcoppe
5cc085c6a6 New translations gpx.mdx (Latvian) 2025-11-12 17:31:59 +01:00
vcoppe
3859779f0a New translations gpx.mdx (Thai) 2025-11-12 17:31:58 +01:00
vcoppe
9857469cd1 New translations gpx.mdx (Indonesian) 2025-11-12 17:31:57 +01:00
vcoppe
ff805d32fb New translations gpx.mdx (Portuguese, Brazilian) 2025-11-12 17:31:55 +01:00
vcoppe
5a94e58236 New translations gpx.mdx (Vietnamese) 2025-11-12 17:31:54 +01:00
vcoppe
9ff7da58ea New translations gpx.mdx (Chinese Simplified) 2025-11-12 17:31:52 +01:00
vcoppe
30d974ff4a New translations gpx.mdx (Ukrainian) 2025-11-12 17:31:51 +01:00
vcoppe
11709dc594 New translations gpx.mdx (Turkish) 2025-11-12 17:31:50 +01:00
vcoppe
246c31bac4 New translations gpx.mdx (Swedish) 2025-11-12 17:31:49 +01:00
vcoppe
2a8cc9ad70 New translations gpx.mdx (Russian) 2025-11-12 17:31:47 +01:00
vcoppe
098844bb4a New translations gpx.mdx (Portuguese) 2025-11-12 17:31:46 +01:00
vcoppe
11a064a05b New translations gpx.mdx (Polish) 2025-11-12 17:31:45 +01:00
vcoppe
b2adf99c2a New translations gpx.mdx (Norwegian) 2025-11-12 17:31:44 +01:00
vcoppe
937d9fab36 New translations gpx.mdx (Dutch) 2025-11-12 17:31:42 +01:00
vcoppe
3c4d5a8324 New translations gpx.mdx (Lithuanian) 2025-11-12 17:31:41 +01:00
vcoppe
d66ae036e5 New translations gpx.mdx (Korean) 2025-11-12 17:31:40 +01:00
vcoppe
28dcb92f4c New translations gpx.mdx (Italian) 2025-11-12 17:31:38 +01:00
vcoppe
162059df91 New translations gpx.mdx (Hungarian) 2025-11-12 17:31:37 +01:00
vcoppe
439085c873 New translations gpx.mdx (Hebrew) 2025-11-12 17:31:36 +01:00
vcoppe
e3c34b6e8b New translations gpx.mdx (Finnish) 2025-11-12 17:31:35 +01:00
vcoppe
8a6e60f961 New translations gpx.mdx (Basque) 2025-11-12 17:31:33 +01:00
vcoppe
eedcf5b2e8 New translations gpx.mdx (Greek) 2025-11-12 17:31:32 +01:00
vcoppe
bad3cb5c7e New translations gpx.mdx (German) 2025-11-12 17:31:31 +01:00
vcoppe
2351a16fae New translations gpx.mdx (Danish) 2025-11-12 17:31:29 +01:00
vcoppe
5779c11097 New translations gpx.mdx (Czech) 2025-11-12 17:31:28 +01:00
vcoppe
ab34ab3429 New translations gpx.mdx (Catalan) 2025-11-12 17:31:27 +01:00
vcoppe
98487a18e5 New translations gpx.mdx (Belarusian) 2025-11-12 17:31:26 +01:00
vcoppe
fae46c2af0 New translations gpx.mdx (Spanish) 2025-11-12 17:31:24 +01:00
vcoppe
2725d3a386 New translations gpx.mdx (French) 2025-11-12 17:31:23 +01:00
vcoppe
4601d61385 New translations gpx.mdx (Romanian) 2025-11-12 17:31:21 +01:00
vcoppe
2b1081b523 New translations files-and-stats.mdx (Serbian (Latin)) 2025-11-12 17:31:05 +01:00
vcoppe
c2bcbd3597 New translations files-and-stats.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:31:03 +01:00
vcoppe
d4367475fb New translations files-and-stats.mdx (Latvian) 2025-11-12 17:31:02 +01:00
vcoppe
e62d7b2132 New translations files-and-stats.mdx (Thai) 2025-11-12 17:31:00 +01:00
vcoppe
13486e3dfa New translations files-and-stats.mdx (Indonesian) 2025-11-12 17:30:59 +01:00
vcoppe
34e4069b2f New translations files-and-stats.mdx (Portuguese, Brazilian) 2025-11-12 17:30:58 +01:00
vcoppe
5ed921d8dc New translations files-and-stats.mdx (Vietnamese) 2025-11-12 17:30:56 +01:00
vcoppe
6e9c2191eb New translations files-and-stats.mdx (Chinese Simplified) 2025-11-12 17:30:55 +01:00
vcoppe
afb9f1ba26 New translations files-and-stats.mdx (Ukrainian) 2025-11-12 17:30:54 +01:00
vcoppe
acc9fd8101 New translations files-and-stats.mdx (Turkish) 2025-11-12 17:30:53 +01:00
vcoppe
60f9b4ae33 New translations files-and-stats.mdx (Swedish) 2025-11-12 17:30:51 +01:00
vcoppe
8d3e241869 New translations files-and-stats.mdx (Russian) 2025-11-12 17:30:50 +01:00
vcoppe
0cfd35e74f New translations files-and-stats.mdx (Portuguese) 2025-11-12 17:30:49 +01:00
vcoppe
dba8fa3a42 New translations files-and-stats.mdx (Polish) 2025-11-12 17:30:47 +01:00
vcoppe
bf4d7101b8 New translations files-and-stats.mdx (Norwegian) 2025-11-12 17:30:46 +01:00
vcoppe
bff1d5fa6f New translations routing.mdx (Czech) 2025-11-12 17:30:45 +01:00
vcoppe
f8f5a59520 New translations files-and-stats.mdx (Dutch) 2025-11-12 17:30:43 +01:00
vcoppe
dc2bd3a5d6 New translations files-and-stats.mdx (Lithuanian) 2025-11-12 17:30:42 +01:00
vcoppe
5b7cf32314 New translations files-and-stats.mdx (Korean) 2025-11-12 17:30:41 +01:00
vcoppe
5f99680b01 New translations files-and-stats.mdx (Italian) 2025-11-12 17:30:39 +01:00
vcoppe
f76c4b0405 New translations files-and-stats.mdx (Hungarian) 2025-11-12 17:30:37 +01:00
vcoppe
d8425ce831 New translations files-and-stats.mdx (Hebrew) 2025-11-12 17:30:36 +01:00
vcoppe
7e42e5bb54 New translations files-and-stats.mdx (Finnish) 2025-11-12 17:30:34 +01:00
vcoppe
7c2533b6fb New translations files-and-stats.mdx (Basque) 2025-11-12 17:30:33 +01:00
vcoppe
ae05034927 New translations files-and-stats.mdx (Greek) 2025-11-12 17:30:31 +01:00
vcoppe
1c331d57b3 New translations files-and-stats.mdx (German) 2025-11-12 17:30:30 +01:00
vcoppe
db5cda70ad New translations files-and-stats.mdx (Danish) 2025-11-12 17:30:28 +01:00
vcoppe
8196ac451d New translations files-and-stats.mdx (Czech) 2025-11-12 17:30:27 +01:00
vcoppe
e2cfeec10a New translations files-and-stats.mdx (Catalan) 2025-11-12 17:30:26 +01:00
vcoppe
9923c1ae65 New translations files-and-stats.mdx (Belarusian) 2025-11-12 17:30:24 +01:00
vcoppe
957634f90c New translations files-and-stats.mdx (Spanish) 2025-11-12 17:30:23 +01:00
vcoppe
6be78b2a54 New translations files-and-stats.mdx (French) 2025-11-12 17:30:21 +01:00
vcoppe
f04972666c New translations files-and-stats.mdx (Romanian) 2025-11-12 17:30:20 +01:00
vcoppe
e25caa6e03 New translations elevation.mdx (Serbian (Latin)) 2025-11-12 17:27:56 +01:00
vcoppe
1525bc447d New translations elevation.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:27:55 +01:00
vcoppe
700dc9e81d New translations elevation.mdx (Latvian) 2025-11-12 17:27:54 +01:00
vcoppe
6aafd74ddf New translations elevation.mdx (Thai) 2025-11-12 17:27:53 +01:00
vcoppe
f519ff427b New translations elevation.mdx (Indonesian) 2025-11-12 17:27:52 +01:00
vcoppe
a89ec4df27 New translations elevation.mdx (Portuguese, Brazilian) 2025-11-12 17:27:51 +01:00
vcoppe
c4cb648a2a New translations elevation.mdx (Vietnamese) 2025-11-12 17:27:50 +01:00
vcoppe
0c8c3feb0c New translations elevation.mdx (Chinese Simplified) 2025-11-12 17:27:49 +01:00
vcoppe
5529eeaa72 New translations elevation.mdx (Ukrainian) 2025-11-12 17:27:48 +01:00
vcoppe
8b1055bdda New translations elevation.mdx (Turkish) 2025-11-12 17:27:47 +01:00
vcoppe
df23d9fe63 New translations elevation.mdx (Swedish) 2025-11-12 17:27:46 +01:00
vcoppe
18328711b2 New translations elevation.mdx (Russian) 2025-11-12 17:27:45 +01:00
vcoppe
dc996ed44c New translations elevation.mdx (Portuguese) 2025-11-12 17:27:44 +01:00
vcoppe
e2bc6e44e2 New translations elevation.mdx (Polish) 2025-11-12 17:27:43 +01:00
vcoppe
b667718fc2 New translations elevation.mdx (Norwegian) 2025-11-12 17:27:43 +01:00
vcoppe
4ae5707068 New translations elevation.mdx (Dutch) 2025-11-12 17:27:42 +01:00
vcoppe
a14e8dbc52 New translations elevation.mdx (Lithuanian) 2025-11-12 17:27:41 +01:00
vcoppe
a9344e0069 New translations elevation.mdx (Korean) 2025-11-12 17:27:40 +01:00
vcoppe
9cdf831dd1 New translations elevation.mdx (Italian) 2025-11-12 17:27:39 +01:00
vcoppe
0857e2fee3 New translations elevation.mdx (Hungarian) 2025-11-12 17:27:38 +01:00
vcoppe
69553c3bf3 New translations elevation.mdx (Hebrew) 2025-11-12 17:27:37 +01:00
vcoppe
5ce5da1443 New translations elevation.mdx (Finnish) 2025-11-12 17:27:36 +01:00
vcoppe
ede95247c7 New translations elevation.mdx (Basque) 2025-11-12 17:27:35 +01:00
vcoppe
9b7294c34c New translations elevation.mdx (Greek) 2025-11-12 17:27:34 +01:00
vcoppe
58ebe8c5eb New translations elevation.mdx (German) 2025-11-12 17:27:33 +01:00
vcoppe
67ed742858 New translations elevation.mdx (Danish) 2025-11-12 17:27:32 +01:00
vcoppe
538a6680e7 New translations elevation.mdx (Czech) 2025-11-12 17:27:31 +01:00
vcoppe
e16d42af87 New translations elevation.mdx (Catalan) 2025-11-12 17:27:30 +01:00
vcoppe
6ee62a7e1f New translations elevation.mdx (Belarusian) 2025-11-12 17:27:29 +01:00
vcoppe
3adfe54788 New translations elevation.mdx (Spanish) 2025-11-12 17:27:28 +01:00
vcoppe
673ac6503f New translations elevation.mdx (French) 2025-11-12 17:27:27 +01:00
vcoppe
b226b87b4b New translations elevation.mdx (Romanian) 2025-11-12 17:27:26 +01:00
vcoppe
a62c0c623a New translations time.mdx (Serbian (Latin)) 2025-11-12 17:27:11 +01:00
vcoppe
e7981f61bb New translations time.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:27:10 +01:00
vcoppe
cfe131815e New translations time.mdx (Latvian) 2025-11-12 17:27:09 +01:00
vcoppe
bee1b32f4c New translations time.mdx (Thai) 2025-11-12 17:27:08 +01:00
vcoppe
5861d61444 New translations time.mdx (Indonesian) 2025-11-12 17:27:07 +01:00
vcoppe
9852f41683 New translations time.mdx (Portuguese, Brazilian) 2025-11-12 17:27:06 +01:00
vcoppe
d1f8ae2c3d New translations time.mdx (Vietnamese) 2025-11-12 17:27:05 +01:00
vcoppe
bdc1c9d6c2 New translations time.mdx (Chinese Simplified) 2025-11-12 17:27:04 +01:00
vcoppe
2b7a7b1da8 New translations time.mdx (Ukrainian) 2025-11-12 17:27:02 +01:00
vcoppe
9ef115b74f New translations time.mdx (Turkish) 2025-11-12 17:27:01 +01:00
vcoppe
3e1395466c New translations time.mdx (Swedish) 2025-11-12 17:27:00 +01:00
vcoppe
54c667cbed New translations time.mdx (Russian) 2025-11-12 17:26:59 +01:00
vcoppe
1e224c588d New translations time.mdx (Portuguese) 2025-11-12 17:26:58 +01:00
vcoppe
5bd410cc4c New translations time.mdx (Polish) 2025-11-12 17:26:57 +01:00
vcoppe
67ad2676a1 New translations time.mdx (Norwegian) 2025-11-12 17:26:56 +01:00
vcoppe
0910f4c002 New translations time.mdx (Dutch) 2025-11-12 17:26:55 +01:00
vcoppe
ea8e91c0cc New translations time.mdx (Lithuanian) 2025-11-12 17:26:54 +01:00
vcoppe
db5517e0c9 New translations time.mdx (Korean) 2025-11-12 17:26:53 +01:00
vcoppe
77d787d5b3 New translations time.mdx (Italian) 2025-11-12 17:26:52 +01:00
vcoppe
69f3180b63 New translations time.mdx (Hungarian) 2025-11-12 17:26:51 +01:00
vcoppe
ee2abb0517 New translations time.mdx (Hebrew) 2025-11-12 17:26:50 +01:00
vcoppe
d7a938a90b New translations time.mdx (Finnish) 2025-11-12 17:26:49 +01:00
vcoppe
4d292a077a New translations time.mdx (Basque) 2025-11-12 17:26:48 +01:00
vcoppe
526c9d674d New translations time.mdx (Greek) 2025-11-12 17:26:47 +01:00
vcoppe
36f624da04 New translations time.mdx (German) 2025-11-12 17:26:46 +01:00
vcoppe
9d2a77599e New translations time.mdx (Danish) 2025-11-12 17:26:45 +01:00
vcoppe
e9fbe7d303 New translations time.mdx (Czech) 2025-11-12 17:26:44 +01:00
vcoppe
47757bde94 New translations time.mdx (Catalan) 2025-11-12 17:26:43 +01:00
vcoppe
f71c410d36 New translations time.mdx (Belarusian) 2025-11-12 17:26:42 +01:00
vcoppe
8f8a3f8de6 New translations time.mdx (Spanish) 2025-11-12 17:26:41 +01:00
vcoppe
a338e87b2e New translations time.mdx (French) 2025-11-12 17:26:40 +01:00
vcoppe
5801c5ddd0 New translations time.mdx (Romanian) 2025-11-12 17:26:40 +01:00
vcoppe
5b71da0223 New translations scissors.mdx (Serbian (Latin)) 2025-11-12 17:26:39 +01:00
vcoppe
ed85728729 New translations scissors.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:26:38 +01:00
vcoppe
9b5b661f1c New translations scissors.mdx (Latvian) 2025-11-12 17:26:37 +01:00
vcoppe
a5790aaba3 New translations scissors.mdx (Thai) 2025-11-12 17:26:36 +01:00
vcoppe
698b149271 New translations scissors.mdx (Indonesian) 2025-11-12 17:26:35 +01:00
vcoppe
15c28b0ecb New translations scissors.mdx (Portuguese, Brazilian) 2025-11-12 17:26:34 +01:00
vcoppe
b38f533ccc New translations scissors.mdx (Vietnamese) 2025-11-12 17:26:33 +01:00
vcoppe
5d61376486 New translations scissors.mdx (Chinese Simplified) 2025-11-12 17:26:32 +01:00
vcoppe
28c9e5ff78 New translations scissors.mdx (Ukrainian) 2025-11-12 17:26:31 +01:00
vcoppe
a35f978a08 New translations scissors.mdx (Turkish) 2025-11-12 17:26:30 +01:00
vcoppe
8fb8ecf9f9 New translations scissors.mdx (Swedish) 2025-11-12 17:26:29 +01:00
vcoppe
c8be4d1e4a New translations scissors.mdx (Russian) 2025-11-12 17:26:27 +01:00
vcoppe
8a13c908f8 New translations scissors.mdx (Portuguese) 2025-11-12 17:26:26 +01:00
vcoppe
0ab9cab46d New translations scissors.mdx (Polish) 2025-11-12 17:26:26 +01:00
vcoppe
ae9e31a127 New translations scissors.mdx (Norwegian) 2025-11-12 17:26:24 +01:00
vcoppe
ea74d066ac New translations scissors.mdx (Dutch) 2025-11-12 17:26:23 +01:00
vcoppe
3a8da3a818 New translations scissors.mdx (Lithuanian) 2025-11-12 17:26:23 +01:00
vcoppe
7b2273d180 New translations scissors.mdx (Korean) 2025-11-12 17:26:22 +01:00
vcoppe
2bba112bc0 New translations scissors.mdx (Italian) 2025-11-12 17:26:21 +01:00
vcoppe
f747ac2fc9 New translations scissors.mdx (Hungarian) 2025-11-12 17:26:19 +01:00
vcoppe
50112ef89f New translations scissors.mdx (Hebrew) 2025-11-12 17:26:18 +01:00
vcoppe
c5cce0a87b New translations scissors.mdx (Finnish) 2025-11-12 17:26:17 +01:00
vcoppe
bae21c87f4 New translations scissors.mdx (Basque) 2025-11-12 17:26:17 +01:00
vcoppe
edf77a6c0f New translations scissors.mdx (Greek) 2025-11-12 17:26:15 +01:00
vcoppe
232a0b2acf New translations scissors.mdx (German) 2025-11-12 17:26:14 +01:00
vcoppe
06381cb094 New translations scissors.mdx (Danish) 2025-11-12 17:26:13 +01:00
vcoppe
33a16bdd51 New translations scissors.mdx (Czech) 2025-11-12 17:26:12 +01:00
vcoppe
1b1276cc90 New translations scissors.mdx (Catalan) 2025-11-12 17:26:11 +01:00
vcoppe
8d2d7a8884 New translations scissors.mdx (Belarusian) 2025-11-12 17:26:10 +01:00
vcoppe
3894c12505 New translations scissors.mdx (Spanish) 2025-11-12 17:26:10 +01:00
vcoppe
504d952581 New translations scissors.mdx (French) 2025-11-12 17:26:09 +01:00
vcoppe
e3777ac18a New translations scissors.mdx (Romanian) 2025-11-12 17:26:08 +01:00
vcoppe
4b087d8943 New translations merge.mdx (Serbian (Latin)) 2025-11-12 17:25:26 +01:00
vcoppe
d383db5874 New translations merge.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:25:24 +01:00
vcoppe
fa75a76ed8 New translations merge.mdx (Latvian) 2025-11-12 17:25:23 +01:00
vcoppe
bc28507ea6 New translations merge.mdx (Thai) 2025-11-12 17:25:22 +01:00
vcoppe
e470df06ec New translations merge.mdx (Indonesian) 2025-11-12 17:25:21 +01:00
vcoppe
9b81157d5d New translations merge.mdx (Portuguese, Brazilian) 2025-11-12 17:25:20 +01:00
vcoppe
40bf919931 New translations merge.mdx (Vietnamese) 2025-11-12 17:25:19 +01:00
vcoppe
e55f7af9b3 New translations merge.mdx (Chinese Simplified) 2025-11-12 17:25:18 +01:00
vcoppe
40495dc86f New translations merge.mdx (Ukrainian) 2025-11-12 17:25:17 +01:00
vcoppe
75f01ca497 New translations merge.mdx (Turkish) 2025-11-12 17:25:16 +01:00
vcoppe
6ebdf327e3 New translations merge.mdx (Swedish) 2025-11-12 17:25:15 +01:00
vcoppe
9741c45e52 New translations merge.mdx (Russian) 2025-11-12 17:25:14 +01:00
vcoppe
d193929dd7 New translations merge.mdx (Portuguese) 2025-11-12 17:25:13 +01:00
vcoppe
19cdc8b3cf New translations merge.mdx (Polish) 2025-11-12 17:25:12 +01:00
vcoppe
8ac97c332d New translations merge.mdx (Norwegian) 2025-11-12 17:25:10 +01:00
vcoppe
1b61bbafa9 New translations merge.mdx (Dutch) 2025-11-12 17:25:09 +01:00
vcoppe
9745b308d2 New translations merge.mdx (Lithuanian) 2025-11-12 17:25:08 +01:00
vcoppe
3f2e162523 New translations merge.mdx (Korean) 2025-11-12 17:25:07 +01:00
vcoppe
9669afb772 New translations merge.mdx (Italian) 2025-11-12 17:25:06 +01:00
vcoppe
105842023b New translations merge.mdx (Hungarian) 2025-11-12 17:25:05 +01:00
vcoppe
224ce99d79 New translations merge.mdx (Hebrew) 2025-11-12 17:25:05 +01:00
vcoppe
1d696edf56 New translations merge.mdx (Finnish) 2025-11-12 17:25:03 +01:00
vcoppe
ae8e244418 New translations merge.mdx (Basque) 2025-11-12 17:25:02 +01:00
vcoppe
a1f51e9400 New translations merge.mdx (Greek) 2025-11-12 17:25:02 +01:00
vcoppe
d0c5392cf9 New translations merge.mdx (German) 2025-11-12 17:25:01 +01:00
vcoppe
998c597e17 New translations merge.mdx (Danish) 2025-11-12 17:25:00 +01:00
vcoppe
c943ffd30a New translations merge.mdx (Czech) 2025-11-12 17:24:59 +01:00
vcoppe
1f54884a38 New translations merge.mdx (Catalan) 2025-11-12 17:24:58 +01:00
vcoppe
df0c91a448 New translations merge.mdx (Belarusian) 2025-11-12 17:24:57 +01:00
vcoppe
cceaaa5106 New translations merge.mdx (Spanish) 2025-11-12 17:24:56 +01:00
vcoppe
380aa3d178 New translations merge.mdx (French) 2025-11-12 17:24:55 +01:00
vcoppe
e1624b4fe6 New translations merge.mdx (Romanian) 2025-11-12 17:24:54 +01:00
vcoppe
e3bf1e8ca1 New translations extract.mdx (Serbian (Latin)) 2025-11-12 17:24:53 +01:00
vcoppe
6cb2553cc3 New translations extract.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:24:52 +01:00
vcoppe
5ad4e86b8a New translations extract.mdx (Latvian) 2025-11-12 17:24:51 +01:00
vcoppe
4d392ff48e New translations extract.mdx (Thai) 2025-11-12 17:24:50 +01:00
vcoppe
16e542d302 New translations extract.mdx (Indonesian) 2025-11-12 17:24:50 +01:00
vcoppe
3a252c911c New translations extract.mdx (Portuguese, Brazilian) 2025-11-12 17:24:49 +01:00
vcoppe
128ce34a41 New translations extract.mdx (Vietnamese) 2025-11-12 17:24:48 +01:00
vcoppe
b65f7231f8 New translations extract.mdx (Chinese Simplified) 2025-11-12 17:24:47 +01:00
vcoppe
7412faf534 New translations extract.mdx (Ukrainian) 2025-11-12 17:24:46 +01:00
vcoppe
ac4dd28927 New translations extract.mdx (Turkish) 2025-11-12 17:24:45 +01:00
vcoppe
1672451def New translations extract.mdx (Swedish) 2025-11-12 17:24:44 +01:00
vcoppe
96269ba437 New translations extract.mdx (Russian) 2025-11-12 17:24:43 +01:00
vcoppe
320a6311c5 New translations extract.mdx (Portuguese) 2025-11-12 17:24:42 +01:00
vcoppe
7c2a96b04a New translations extract.mdx (Polish) 2025-11-12 17:24:41 +01:00
vcoppe
704359cdba New translations extract.mdx (Norwegian) 2025-11-12 17:24:40 +01:00
vcoppe
87a1a893bf New translations extract.mdx (Dutch) 2025-11-12 17:24:39 +01:00
vcoppe
66421250b4 New translations extract.mdx (Lithuanian) 2025-11-12 17:24:38 +01:00
vcoppe
374f2b22b5 New translations extract.mdx (Korean) 2025-11-12 17:24:37 +01:00
vcoppe
5b3a287bf4 New translations extract.mdx (Italian) 2025-11-12 17:24:36 +01:00
vcoppe
56bc9ca01a New translations extract.mdx (Hungarian) 2025-11-12 17:24:35 +01:00
vcoppe
18dcbf3568 New translations extract.mdx (Hebrew) 2025-11-12 17:24:34 +01:00
vcoppe
bfc8dd681d New translations extract.mdx (Finnish) 2025-11-12 17:24:33 +01:00
vcoppe
86412f34f3 New translations extract.mdx (Basque) 2025-11-12 17:24:32 +01:00
vcoppe
fc416db742 New translations extract.mdx (Greek) 2025-11-12 17:24:31 +01:00
vcoppe
a6a5092abd New translations extract.mdx (German) 2025-11-12 17:24:30 +01:00
vcoppe
522360bbe7 New translations extract.mdx (Danish) 2025-11-12 17:24:29 +01:00
vcoppe
ed5b6e4b1d New translations extract.mdx (Czech) 2025-11-12 17:24:28 +01:00
vcoppe
4224fe3211 New translations extract.mdx (Catalan) 2025-11-12 17:24:28 +01:00
vcoppe
cb99d5b007 New translations extract.mdx (Belarusian) 2025-11-12 17:24:27 +01:00
vcoppe
be1ba18f7f New translations extract.mdx (Spanish) 2025-11-12 17:24:26 +01:00
vcoppe
52b54d8584 New translations extract.mdx (French) 2025-11-12 17:24:25 +01:00
vcoppe
665822d2f8 New translations extract.mdx (Romanian) 2025-11-12 17:24:24 +01:00
vcoppe
fbfa3b2974 New translations clean.mdx (Serbian (Latin)) 2025-11-12 17:24:23 +01:00
vcoppe
b3b0ef8f0b New translations clean.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:24:22 +01:00
vcoppe
d255b29832 New translations clean.mdx (Latvian) 2025-11-12 17:24:21 +01:00
vcoppe
5a0198d10d New translations clean.mdx (Thai) 2025-11-12 17:24:20 +01:00
vcoppe
3781a118a1 New translations clean.mdx (Indonesian) 2025-11-12 17:24:19 +01:00
vcoppe
ca3262d554 New translations clean.mdx (Portuguese, Brazilian) 2025-11-12 17:24:18 +01:00
vcoppe
c5194b254a New translations clean.mdx (Vietnamese) 2025-11-12 17:24:17 +01:00
vcoppe
4a369b8af7 New translations clean.mdx (Chinese Simplified) 2025-11-12 17:24:16 +01:00
vcoppe
93bddd469d New translations clean.mdx (Ukrainian) 2025-11-12 17:24:15 +01:00
vcoppe
8cf3d028b7 New translations clean.mdx (Turkish) 2025-11-12 17:24:14 +01:00
vcoppe
23af660dc0 New translations clean.mdx (Swedish) 2025-11-12 17:24:13 +01:00
vcoppe
11119654eb New translations clean.mdx (Russian) 2025-11-12 17:24:12 +01:00
vcoppe
6f9d3b7fa9 New translations clean.mdx (Portuguese) 2025-11-12 17:24:11 +01:00
vcoppe
0c64ffd362 New translations clean.mdx (Polish) 2025-11-12 17:24:10 +01:00
vcoppe
5d316d4d94 New translations clean.mdx (Norwegian) 2025-11-12 17:24:09 +01:00
vcoppe
aa3df50a30 New translations clean.mdx (Dutch) 2025-11-12 17:24:08 +01:00
vcoppe
8308a78834 New translations clean.mdx (Lithuanian) 2025-11-12 17:24:07 +01:00
vcoppe
c02a0f27ca New translations clean.mdx (Korean) 2025-11-12 17:24:06 +01:00
vcoppe
d36d798c1b New translations clean.mdx (Italian) 2025-11-12 17:24:05 +01:00
vcoppe
7eb5ed7bcd New translations clean.mdx (Hungarian) 2025-11-12 17:24:04 +01:00
vcoppe
584aa6bf5f New translations clean.mdx (Hebrew) 2025-11-12 17:24:03 +01:00
vcoppe
d90915e25b New translations clean.mdx (Finnish) 2025-11-12 17:24:02 +01:00
vcoppe
e787abb6de New translations clean.mdx (Basque) 2025-11-12 17:24:01 +01:00
vcoppe
7e4c2f2cf3 New translations clean.mdx (Greek) 2025-11-12 17:24:00 +01:00
vcoppe
fb709389c4 New translations clean.mdx (German) 2025-11-12 17:23:59 +01:00
vcoppe
f22fe9eec1 New translations clean.mdx (Danish) 2025-11-12 17:23:58 +01:00
vcoppe
4352309f9f New translations clean.mdx (Czech) 2025-11-12 17:23:57 +01:00
vcoppe
687f3533c8 New translations clean.mdx (Catalan) 2025-11-12 17:23:56 +01:00
vcoppe
2b99240be3 New translations clean.mdx (Belarusian) 2025-11-12 17:23:55 +01:00
vcoppe
e03fa42d63 New translations clean.mdx (Spanish) 2025-11-12 17:23:54 +01:00
vcoppe
8639ed4d00 New translations clean.mdx (French) 2025-11-12 17:23:53 +01:00
vcoppe
cf9dca228a New translations clean.mdx (Romanian) 2025-11-12 17:23:52 +01:00
vcoppe
be66de7584 New translations view.mdx (Serbian (Latin)) 2025-11-12 17:23:37 +01:00
vcoppe
f47b73ee01 New translations view.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:23:36 +01:00
vcoppe
d5703b0e43 New translations view.mdx (Latvian) 2025-11-12 17:23:35 +01:00
vcoppe
293e10e944 New translations view.mdx (Thai) 2025-11-12 17:23:34 +01:00
vcoppe
328baea33b New translations view.mdx (Indonesian) 2025-11-12 17:23:34 +01:00
vcoppe
6c0b38d946 New translations view.mdx (Portuguese, Brazilian) 2025-11-12 17:23:32 +01:00
vcoppe
c0221fc0e5 New translations view.mdx (Vietnamese) 2025-11-12 17:23:32 +01:00
vcoppe
f1418ee99d New translations view.mdx (Chinese Simplified) 2025-11-12 17:23:31 +01:00
vcoppe
111bd40b94 New translations view.mdx (Ukrainian) 2025-11-12 17:23:30 +01:00
vcoppe
ef477f5642 New translations view.mdx (Turkish) 2025-11-12 17:23:28 +01:00
vcoppe
c785fc957c New translations view.mdx (Swedish) 2025-11-12 17:23:28 +01:00
vcoppe
eeaeed9d83 New translations view.mdx (Russian) 2025-11-12 17:23:27 +01:00
vcoppe
483d66b2cd New translations view.mdx (Portuguese) 2025-11-12 17:23:26 +01:00
vcoppe
e2332ee760 New translations view.mdx (Polish) 2025-11-12 17:23:25 +01:00
vcoppe
14e3cb049e New translations view.mdx (Norwegian) 2025-11-12 17:23:24 +01:00
vcoppe
c021d2fa5b New translations view.mdx (Dutch) 2025-11-12 17:23:23 +01:00
vcoppe
20e365b0c0 New translations view.mdx (Lithuanian) 2025-11-12 17:23:22 +01:00
vcoppe
1ef4c9878d New translations view.mdx (Korean) 2025-11-12 17:23:21 +01:00
vcoppe
7e47a68acf New translations view.mdx (Italian) 2025-11-12 17:23:20 +01:00
vcoppe
fe5502e0dd New translations view.mdx (Hungarian) 2025-11-12 17:23:19 +01:00
vcoppe
392cc2ba43 New translations view.mdx (Hebrew) 2025-11-12 17:23:18 +01:00
vcoppe
b93c15bc38 New translations view.mdx (Finnish) 2025-11-12 17:23:17 +01:00
vcoppe
5d86c6f0e1 New translations view.mdx (Basque) 2025-11-12 17:23:16 +01:00
vcoppe
dbfca7c677 New translations view.mdx (Greek) 2025-11-12 17:23:15 +01:00
vcoppe
714c8d2e1c New translations view.mdx (German) 2025-11-12 17:23:14 +01:00
vcoppe
e6e0f259f4 New translations view.mdx (Danish) 2025-11-12 17:23:13 +01:00
vcoppe
a03580a64d New translations view.mdx (Czech) 2025-11-12 17:23:12 +01:00
vcoppe
588f80c282 New translations view.mdx (Catalan) 2025-11-12 17:23:11 +01:00
vcoppe
4e3bedac31 New translations view.mdx (Belarusian) 2025-11-12 17:23:10 +01:00
vcoppe
953c1eb639 New translations view.mdx (Spanish) 2025-11-12 17:23:09 +01:00
vcoppe
aa4f14c461 New translations view.mdx (French) 2025-11-12 17:23:08 +01:00
vcoppe
6937c86e66 New translations view.mdx (Romanian) 2025-11-12 17:23:07 +01:00
vcoppe
c1ea4741eb New translations settings.mdx (Serbian (Latin)) 2025-11-12 17:23:05 +01:00
vcoppe
d4d5edd0f3 New translations settings.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:23:04 +01:00
vcoppe
3d8dd63fad New translations settings.mdx (Latvian) 2025-11-12 17:23:03 +01:00
vcoppe
8bc6477e88 New translations settings.mdx (Thai) 2025-11-12 17:23:02 +01:00
vcoppe
5743814d90 New translations settings.mdx (Indonesian) 2025-11-12 17:23:01 +01:00
vcoppe
d631b3d799 New translations settings.mdx (Portuguese, Brazilian) 2025-11-12 17:23:00 +01:00
vcoppe
6b4cf58454 New translations settings.mdx (Vietnamese) 2025-11-12 17:22:59 +01:00
vcoppe
e864e7c2bf New translations settings.mdx (Chinese Simplified) 2025-11-12 17:22:58 +01:00
vcoppe
ebca3f0f5f New translations settings.mdx (Ukrainian) 2025-11-12 17:22:57 +01:00
vcoppe
8216ba11b8 New translations settings.mdx (Turkish) 2025-11-12 17:22:56 +01:00
vcoppe
ae67f84476 New translations settings.mdx (Swedish) 2025-11-12 17:22:56 +01:00
vcoppe
59f1313bb2 New translations settings.mdx (Russian) 2025-11-12 17:22:54 +01:00
vcoppe
04341c8c3c New translations settings.mdx (Portuguese) 2025-11-12 17:22:53 +01:00
vcoppe
0a315b50b4 New translations settings.mdx (Polish) 2025-11-12 17:22:52 +01:00
vcoppe
36a453f6c0 New translations settings.mdx (Norwegian) 2025-11-12 17:22:51 +01:00
vcoppe
991f079deb New translations settings.mdx (Dutch) 2025-11-12 17:22:50 +01:00
vcoppe
5110b4449c New translations settings.mdx (Lithuanian) 2025-11-12 17:22:49 +01:00
vcoppe
0e8fb9d56d New translations settings.mdx (Korean) 2025-11-12 17:22:48 +01:00
vcoppe
868dd01952 New translations settings.mdx (Italian) 2025-11-12 17:22:47 +01:00
vcoppe
47be4ed8dc New translations settings.mdx (Hungarian) 2025-11-12 17:22:46 +01:00
vcoppe
392c00a635 New translations settings.mdx (Hebrew) 2025-11-12 17:22:45 +01:00
vcoppe
aaa99ded43 New translations settings.mdx (Finnish) 2025-11-12 17:22:44 +01:00
vcoppe
838a6be13d New translations settings.mdx (Basque) 2025-11-12 17:22:43 +01:00
vcoppe
b546e130bc New translations settings.mdx (Greek) 2025-11-12 17:22:42 +01:00
vcoppe
3254afce29 New translations settings.mdx (German) 2025-11-12 17:22:41 +01:00
vcoppe
417a113ca7 New translations settings.mdx (Danish) 2025-11-12 17:22:40 +01:00
vcoppe
aef814b094 New translations settings.mdx (Czech) 2025-11-12 17:22:39 +01:00
vcoppe
47075dadec New translations settings.mdx (Catalan) 2025-11-12 17:22:38 +01:00
vcoppe
c1e9e0c55f New translations settings.mdx (Belarusian) 2025-11-12 17:22:37 +01:00
vcoppe
70b0698504 New translations settings.mdx (Spanish) 2025-11-12 17:22:36 +01:00
vcoppe
3f00534549 New translations settings.mdx (French) 2025-11-12 17:22:34 +01:00
vcoppe
392fbf6af4 New translations settings.mdx (Romanian) 2025-11-12 17:22:33 +01:00
vcoppe
552b18148f New translations file.mdx (Spanish) 2025-11-12 17:22:20 +01:00
vcoppe
08007ad3b2 New translations edit.mdx (Serbian (Latin)) 2025-11-12 17:22:18 +01:00
vcoppe
e8890e6ba3 New translations edit.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:22:17 +01:00
vcoppe
b10e4fc487 New translations edit.mdx (Latvian) 2025-11-12 17:22:15 +01:00
vcoppe
1ce92bcfbc New translations edit.mdx (Thai) 2025-11-12 17:22:15 +01:00
vcoppe
7e9cd2f672 New translations edit.mdx (Indonesian) 2025-11-12 17:22:14 +01:00
vcoppe
bc1c06b01f New translations edit.mdx (Portuguese, Brazilian) 2025-11-12 17:22:13 +01:00
vcoppe
2be6d36fc6 New translations edit.mdx (Vietnamese) 2025-11-12 17:22:11 +01:00
vcoppe
75f7aeb588 New translations edit.mdx (Chinese Simplified) 2025-11-12 17:22:10 +01:00
vcoppe
2944694492 New translations edit.mdx (Ukrainian) 2025-11-12 17:22:09 +01:00
vcoppe
53b29b5bdd New translations edit.mdx (Turkish) 2025-11-12 17:22:08 +01:00
vcoppe
0d4f73b269 New translations edit.mdx (Swedish) 2025-11-12 17:22:07 +01:00
vcoppe
f5f2d2734d New translations edit.mdx (Russian) 2025-11-12 17:22:06 +01:00
vcoppe
4b5507333c New translations edit.mdx (Portuguese) 2025-11-12 17:22:05 +01:00
vcoppe
7d6d4d4cec New translations edit.mdx (Polish) 2025-11-12 17:22:04 +01:00
vcoppe
3c86cb0e69 New translations edit.mdx (Norwegian) 2025-11-12 17:22:03 +01:00
vcoppe
73f5d46e76 New translations edit.mdx (Dutch) 2025-11-12 17:22:01 +01:00
vcoppe
8cd02ac3ed New translations edit.mdx (Lithuanian) 2025-11-12 17:22:00 +01:00
vcoppe
8b208f9dbd New translations edit.mdx (Korean) 2025-11-12 17:21:59 +01:00
vcoppe
11065519aa New translations edit.mdx (Italian) 2025-11-12 17:21:58 +01:00
vcoppe
5124f9a054 New translations edit.mdx (Hungarian) 2025-11-12 17:21:57 +01:00
vcoppe
6c0646acc6 New translations edit.mdx (Hebrew) 2025-11-12 17:21:56 +01:00
vcoppe
60bfc8f856 New translations edit.mdx (Finnish) 2025-11-12 17:21:55 +01:00
vcoppe
c57f0f84af New translations edit.mdx (Basque) 2025-11-12 17:21:54 +01:00
vcoppe
cd1a5bc169 New translations edit.mdx (Greek) 2025-11-12 17:21:53 +01:00
vcoppe
27f0fe30e7 New translations edit.mdx (German) 2025-11-12 17:21:52 +01:00
vcoppe
c24e008865 New translations edit.mdx (Danish) 2025-11-12 17:21:51 +01:00
vcoppe
a852ed88f7 New translations edit.mdx (Czech) 2025-11-12 17:21:50 +01:00
vcoppe
57236e7010 New translations edit.mdx (Catalan) 2025-11-12 17:21:49 +01:00
vcoppe
1f7389d1e1 New translations edit.mdx (Belarusian) 2025-11-12 17:21:48 +01:00
vcoppe
3a213a273d New translations edit.mdx (Spanish) 2025-11-12 17:21:47 +01:00
vcoppe
7602c70d98 New translations edit.mdx (French) 2025-11-12 17:21:46 +01:00
vcoppe
54fda40fcf New translations edit.mdx (Romanian) 2025-11-12 17:21:45 +01:00
vcoppe
ce038fde8c New translations map-controls.mdx (Serbian (Latin)) 2025-11-12 17:21:30 +01:00
vcoppe
dbe2c2be9b New translations map-controls.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:21:29 +01:00
vcoppe
85c66f1b1c New translations map-controls.mdx (Latvian) 2025-11-12 17:21:28 +01:00
vcoppe
4b72468f0c New translations map-controls.mdx (Thai) 2025-11-12 17:21:27 +01:00
vcoppe
ecaedf15d0 New translations map-controls.mdx (Indonesian) 2025-11-12 17:21:26 +01:00
vcoppe
1d9bee9fe7 New translations map-controls.mdx (Portuguese, Brazilian) 2025-11-12 17:21:25 +01:00
vcoppe
22a30cef11 New translations map-controls.mdx (Vietnamese) 2025-11-12 17:21:24 +01:00
vcoppe
def7a36f70 New translations map-controls.mdx (Chinese Simplified) 2025-11-12 17:21:23 +01:00
vcoppe
064adcfc2a New translations map-controls.mdx (Ukrainian) 2025-11-12 17:21:22 +01:00
vcoppe
c84eedbce0 New translations map-controls.mdx (Turkish) 2025-11-12 17:21:21 +01:00
vcoppe
10babaa9e6 New translations map-controls.mdx (Swedish) 2025-11-12 17:21:20 +01:00
vcoppe
2f8f3264df New translations map-controls.mdx (Russian) 2025-11-12 17:21:19 +01:00
vcoppe
5f58762dda New translations map-controls.mdx (Portuguese) 2025-11-12 17:21:18 +01:00
vcoppe
e293000db9 New translations map-controls.mdx (Polish) 2025-11-12 17:21:17 +01:00
vcoppe
cff344cda5 New translations map-controls.mdx (Norwegian) 2025-11-12 17:21:16 +01:00
vcoppe
ba9c622d7a New translations map-controls.mdx (Dutch) 2025-11-12 17:21:15 +01:00
vcoppe
8b44770072 New translations map-controls.mdx (Lithuanian) 2025-11-12 17:21:14 +01:00
vcoppe
371c5c8c32 New translations map-controls.mdx (Korean) 2025-11-12 17:21:13 +01:00
vcoppe
ddac691e8d New translations map-controls.mdx (Italian) 2025-11-12 17:21:12 +01:00
vcoppe
71b658612c New translations map-controls.mdx (Hungarian) 2025-11-12 17:21:11 +01:00
vcoppe
6f3bba910e New translations map-controls.mdx (Hebrew) 2025-11-12 17:21:10 +01:00
vcoppe
4e197885c8 New translations map-controls.mdx (Finnish) 2025-11-12 17:21:09 +01:00
vcoppe
8802375dc0 New translations map-controls.mdx (Basque) 2025-11-12 17:21:08 +01:00
vcoppe
5298879240 New translations map-controls.mdx (Greek) 2025-11-12 17:21:07 +01:00
vcoppe
2f8f40df0d New translations map-controls.mdx (German) 2025-11-12 17:21:06 +01:00
vcoppe
3c3216f384 New translations map-controls.mdx (Danish) 2025-11-12 17:21:04 +01:00
vcoppe
346c9308f2 New translations map-controls.mdx (Czech) 2025-11-12 17:21:03 +01:00
vcoppe
7468c52865 New translations map-controls.mdx (Catalan) 2025-11-12 17:21:02 +01:00
vcoppe
49c7fb5d67 New translations map-controls.mdx (Belarusian) 2025-11-12 17:21:01 +01:00
vcoppe
ad3d11daf4 New translations map-controls.mdx (Spanish) 2025-11-12 17:21:00 +01:00
vcoppe
4e5d485157 New translations map-controls.mdx (French) 2025-11-12 17:20:59 +01:00
vcoppe
e9097670f8 New translations map-controls.mdx (Romanian) 2025-11-12 17:20:58 +01:00
vcoppe
53ba24a087 New translations gpx.mdx (Serbian (Latin)) 2025-11-12 17:20:03 +01:00
vcoppe
271d046248 New translations gpx.mdx (Chinese Traditional, Hong Kong) 2025-11-12 17:20:02 +01:00
vcoppe
beffe9cdcc New translations gpx.mdx (Latvian) 2025-11-12 17:20:01 +01:00
vcoppe
e7a375d912 New translations gpx.mdx (Thai) 2025-11-12 17:20:00 +01:00
vcoppe
663dd96019 New translations gpx.mdx (Indonesian) 2025-11-12 17:19:59 +01:00
vcoppe
7eeb33a973 New translations gpx.mdx (Portuguese, Brazilian) 2025-11-12 17:19:58 +01:00
vcoppe
e95195151d New translations gpx.mdx (Vietnamese) 2025-11-12 17:19:57 +01:00
vcoppe
d81c2e190a New translations gpx.mdx (Chinese Simplified) 2025-11-12 17:19:56 +01:00
vcoppe
6628a52b66 New translations gpx.mdx (Ukrainian) 2025-11-12 17:19:55 +01:00
vcoppe
e5ff306200 New translations gpx.mdx (Turkish) 2025-11-12 17:19:54 +01:00
vcoppe
43f4023370 New translations gpx.mdx (Swedish) 2025-11-12 17:19:53 +01:00
vcoppe
5f6956dd47 New translations gpx.mdx (Russian) 2025-11-12 17:19:52 +01:00
vcoppe
91247b1bb5 New translations gpx.mdx (Portuguese) 2025-11-12 17:19:51 +01:00
vcoppe
f4056986fa New translations gpx.mdx (Polish) 2025-11-12 17:19:50 +01:00
vcoppe
30a752928f New translations gpx.mdx (Norwegian) 2025-11-12 17:19:49 +01:00
vcoppe
bff66cf0dd New translations gpx.mdx (Dutch) 2025-11-12 17:19:48 +01:00
vcoppe
1abf0bc157 New translations gpx.mdx (Lithuanian) 2025-11-12 17:19:47 +01:00
vcoppe
6e845e765d New translations gpx.mdx (Korean) 2025-11-12 17:19:46 +01:00
vcoppe
ef18c1a673 New translations gpx.mdx (Italian) 2025-11-12 17:19:45 +01:00
vcoppe
a32460b8d0 New translations gpx.mdx (Hungarian) 2025-11-12 17:19:44 +01:00
vcoppe
aedfaa05ca New translations gpx.mdx (Hebrew) 2025-11-12 17:19:43 +01:00
vcoppe
78a30a7f51 New translations gpx.mdx (Finnish) 2025-11-12 17:19:42 +01:00
vcoppe
a59215e082 New translations gpx.mdx (Basque) 2025-11-12 17:19:41 +01:00
vcoppe
a41f084fad New translations gpx.mdx (Greek) 2025-11-12 17:19:40 +01:00
vcoppe
28edec8158 New translations gpx.mdx (German) 2025-11-12 17:19:39 +01:00
vcoppe
9870a631c4 New translations gpx.mdx (Danish) 2025-11-12 17:19:38 +01:00
vcoppe
5027f188c9 New translations gpx.mdx (Czech) 2025-11-12 17:19:37 +01:00
vcoppe
df157a5940 New translations gpx.mdx (Catalan) 2025-11-12 17:19:36 +01:00
vcoppe
1a50843ee0 New translations gpx.mdx (Belarusian) 2025-11-12 17:19:35 +01:00
vcoppe
fb1b365fcf New translations gpx.mdx (Spanish) 2025-11-12 17:19:34 +01:00
vcoppe
393568499d New translations gpx.mdx (French) 2025-11-12 17:19:33 +01:00
vcoppe
b24bf11192 New translations gpx.mdx (Romanian) 2025-11-12 17:19:32 +01:00
vcoppe
1e5db9dc6c fix import 2025-11-12 16:17:25 +01:00
vcoppe
252dc10e61 Merge remote-tracking branch 'origin/l10n' into dev 2025-11-12 16:08:18 +01:00
vcoppe
f9e2315ba1 only show layer if it has been activated before 2025-11-12 15:50:05 +01:00
vcoppe
0eca588280 update extension api 2025-11-12 14:48:17 +01:00
vcoppe
33523bbfb9 New translations files-and-stats.mdx (Lithuanian) 2025-11-12 12:56:32 +01:00
vcoppe
110f23bdf1 update extension api 2025-11-12 12:47:26 +01:00
vcoppe
50a5cb23f5 remove unused imports 2025-11-12 11:39:51 +01:00
vcoppe
30e72db5ea hide horizontal scroll bar 2025-11-12 09:05:20 +01:00
vcoppe
c4c64c8fe8 load files from urls/ids once local ones are loaded 2025-11-12 09:02:09 +01:00
vcoppe
0f7f64fb2f migrate component 2025-11-11 17:30:06 +01:00
vcoppe
b09a1fdcb7 migrate component 2025-11-11 17:23:24 +01:00
vcoppe
e5d45dee3a fix hidden computation for new files 2025-11-11 14:03:07 +01:00
vcoppe
8c3365ef24 update nz basemap 2025-11-11 13:00:34 +01:00
vcoppe
db5cbffb70 api for adding overlays from extensions 2025-11-11 12:11:38 +01:00
vcoppe
683ac4e118 clean custom layer logic 2025-11-11 10:37:06 +01:00
vcoppe
88c9abb78e open collapsible if an item item is selected 2025-11-11 09:31:08 +01:00
vcoppe
1729a2f734 remove dead code 2025-11-11 09:29:07 +01:00
vcoppe
c6798dbcd5 delete empty file 2025-11-10 19:06:56 +01:00
vcoppe
d490dc0a8b match updated wording 2025-11-10 19:02:03 +01:00
vcoppe
36c6c623de fix crawling 2025-11-10 18:37:31 +01:00
vcoppe
e334419e24 fix import 2025-11-10 16:54:09 +01:00
vcoppe
01240c4f2a fix spacing 2025-11-10 16:47:16 +01:00
vcoppe
431a9ce827 migrate component 2025-11-10 16:45:50 +01:00
vcoppe
20ab41c3b4 update lucid icon name 2025-11-10 16:23:35 +01:00
vcoppe
3f4ea27be2 update gitignore 2025-11-10 16:07:06 +01:00
vcoppe
5bb5b2f8c8 fix destroy 2025-11-10 16:03:03 +01:00
vcoppe
e9bb9e27bb fix spacing 2025-11-10 16:02:54 +01:00
vcoppe
ee1dd1fae7 migrate component 2025-11-10 15:47:43 +01:00
vcoppe
738530a960 remove active layers when removed from selection 2025-11-10 15:26:12 +01:00
vcoppe
16023b0688 fix some typescript errors 2025-11-10 13:11:44 +01:00
vcoppe
bce7b5984f fix footer spacing 2025-11-10 11:56:28 +01:00
vcoppe
4e5d7d391a small style fixes 2025-11-10 11:51:16 +01:00
vcoppe
0554a85e01 fix toolbar animation 2025-11-10 11:11:37 +01:00
vcoppe
b2a5462372 improve website link 2025-11-09 20:14:52 +01:00
vcoppe
9d61f51270 fix spacing 2025-11-09 20:11:35 +01:00
vcoppe
a0eb7d61cc remove swedish map 2025-11-09 20:05:00 +01:00
vcoppe
9861b319f4 fix popup max height 2025-11-09 19:52:02 +01:00
vcoppe
b04e0f10b2 resize map on load 2025-11-09 19:20:10 +01:00
vcoppe
e6d089b34b close -> delete 2025-11-09 19:09:16 +01:00
vcoppe
9df014e986 fix sortable 2025-11-09 19:00:33 +01:00
vcoppe
39b8d2e70d initialize missing settings 2025-11-09 18:45:20 +01:00
vcoppe
59710d2e1a fix embedding + playground 2025-11-09 18:03:27 +01:00
vcoppe
ec3eb387e5 sortable file list, to be fixed 2025-11-02 16:01:17 +01:00
vcoppe
722cf58486 progress 2025-10-26 12:12:23 +01:00
vcoppe
17e5347d55 update date 2025-10-26 11:04:23 +01:00
vcoppe
2e828dfde3 migrate custom layers sortable list from sortablejs to svelte-dnd-action 2025-10-25 18:34:24 +02:00
vcoppe
1b035bcde3 fix metadata and style dialogs 2025-10-25 17:44:41 +02:00
vcoppe
30981130c9 fix menu not closing 2025-10-25 15:05:11 +02:00
vcoppe
6db8696a36 small fixes for tools 2025-10-24 20:07:15 +02:00
vcoppe
9c83dcafa7 fix gpx markers 2025-10-24 20:06:54 +02:00
vcoppe
1db9ecafef fix coordinates popup 2025-10-23 19:07:32 +02:00
vcoppe
aa624e2c60 renaming 2025-10-23 18:58:33 +02:00
vcoppe
bde7e3e8aa rename files 2025-10-23 18:56:04 +02:00
vcoppe
b2b3e1b153 clean scissors logic 2025-10-23 18:54:01 +02:00
vcoppe
76b3d09320 fix layer control 2025-10-23 18:42:10 +02:00
vcoppe
899dcddd2e fix custom layer creation 2025-10-22 21:54:22 +02:00
vcoppe
9edae7e1b8 fix elevation profile 2025-10-22 19:05:20 +02:00
vcoppe
d73b684999 move file 2025-10-20 20:17:47 +02:00
vcoppe
a78bd8d7ca minor fixes 2025-10-20 19:53:42 +02:00
vcoppe
2ca53c1004 fix 2025-10-19 16:51:30 +02:00
vcoppe
d621516d59 fix separator 2025-10-19 16:47:23 +02:00
vcoppe
ef310cc3cc fix elevation profile toggle 2025-10-19 16:45:12 +02:00
vcoppe
776c867c0b fix xs selector 2025-10-19 16:44:15 +02:00
vcoppe
8abe0ec333 fix fill 2025-10-19 16:19:22 +02:00
vcoppe
e57ced0ce7 bounds management 2025-10-19 16:14:05 +02:00
vcoppe
117c46341b add utagawaVTT layer 2025-10-19 14:19:44 +02:00
vcoppe
ba1ac69151 start/end & distance markers 2025-10-19 14:15:52 +02:00
vcoppe
a8d3af35de fix gap 2025-10-19 13:55:01 +02:00
vcoppe
307eed86e3 fix export 2025-10-19 13:51:56 +02:00
vcoppe
3f103323c7 fix hiding 2025-10-19 13:45:05 +02:00
vcoppe
05df3ca064 start fixing elevation profile 2025-10-18 20:12:19 +02:00
vcoppe
356884cf58 starting to fix time tool 2025-10-18 19:21:10 +02:00
vcoppe
e68da7354e update shadcn components 2025-10-18 18:51:11 +02:00
vcoppe
c59cd66141 fix tools 2025-10-18 16:10:08 +02:00
vcoppe
9fa8fe5767 fix copied & cut stores 2025-10-18 09:36:55 +02:00
vcoppe
4ae0bc25c2 update selection on file removal 2025-10-18 00:46:59 +02:00
vcoppe
de81a8940e statistics 2025-10-18 00:31:14 +02:00
vcoppe
a73da0d81d progress 2025-10-17 23:54:45 +02:00
vcoppe
0733562c0d progress 2025-10-05 19:34:05 +02:00
vcoppe
1cc07901f6 progress 2025-06-21 21:07:36 +02:00
vcoppe
f0230d4634 commit before upgrading to tailwind 4 2025-06-08 16:32:41 +02:00
vcoppe
228ad1044e remove svelte-i18n dependency, replace with minimalistic implementation 2025-06-08 13:49:39 +02:00
676 changed files with 14895 additions and 13773 deletions

View File

@@ -9,7 +9,7 @@
"files": "**/*.svelte",
"options": {
"plugins": ["prettier-plugin-svelte"],
"parser": "svelte"
"parser": "svelte"
}
}
]

View File

@@ -26,8 +26,9 @@ Any help is greatly appreciated!
## Development
The code is split into two parts:
- `gpx`: a Typescript library for parsing and manipulating GPX files,
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
- `gpx`: a Typescript library for parsing and manipulating GPX files,
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
You will need [Node.js](https://nodejs.org/) to build and run these two parts.
@@ -54,26 +55,25 @@ npm run dev
This project has been made possible thanks to the following open source projects:
- Development:
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience
- [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation
- [svelte-i18n](https://github.com/kaisermann/svelte-i18n) — easy localization
- Design:
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
- [lucide-svelte](https://github.com/lucide-icons/lucide/tree/main/packages/lucide-svelte) — beautiful icons
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
- Logic:
- [immer](https://github.com/immerjs/immer) — complex state management
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
- Mapping:
- [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
- Development:
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience
- [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation
- Design:
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
- [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
- Logic:
- [immer](https://github.com/immerjs/immer) — complex state management
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
- Mapping:
- [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

View File

@@ -1 +0,0 @@
package-lock.json

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "gpx.studio",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

2
website/.gitignore vendored
View File

@@ -8,3 +8,5 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
static/*.webmanifest
!static/en.manifest.webmanifest

View File

@@ -1,14 +1,17 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app.css",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils"
},
"typescript": true
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"css": "src/app.css",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}

6385
website/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,8 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
"prebuild": "npx tsx src/lib/pwa-manifest.ts",
"postbuild": "npx tsx src/lib/sitemap.ts",
"prebuild": "npx tsx src/lib/scripts/pwa-manifest.ts",
"postbuild": "npx tsx src/lib/scripts/sitemap.ts",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -14,69 +14,71 @@
"format": "prettier --write ."
},
"devDependencies": {
"@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",
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.6.0",
"@sveltejs/kit": "^2.21.2",
"@sveltejs/vite-plugin-svelte": "^5.1.0",
"@tailwindcss/vite": "^4.1.8",
"@types/eslint": "^9.6.1",
"@types/events": "^3.0.3",
"@types/file-saver": "^2.0.7",
"@types/mapbox__tilebelt": "^1.0.4",
"@types/mapbox-gl": "^3.4.0",
"@types/node": "^20.16.10",
"@types/mapbox-gl": "^3.4.1",
"@types/node": "^22.15.30",
"@types/png.js": "^0.2.3",
"@types/sanitize-html": "^2.13.0",
"@types/sanitize-html": "^2.16.0",
"@types/sortablejs": "^1.15.8",
"@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.44.1",
"@typescript-eslint/eslint-plugin": "^8.33.1",
"@typescript-eslint/parser": "^8.33.1",
"bits-ui": "^2.12.0",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.9.1",
"events": "^3.3.0",
"glob": "^10.4.5",
"glob": "^11.0.2",
"lucide-static": "^0.513.0",
"mdsvex": "^0.12.6",
"mode-watcher": "^1.1.0",
"paneforge": "^1.0.0-next.5",
"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",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.33.18",
"svelte-check": "^4.0.0",
"svelte-dnd-action": "^0.9.65",
"svelte-sonner": "^1.0.5",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.1.8",
"tslib": "^2.8.1",
"tsx": "^4.19.1",
"typescript": "^5.6.2",
"vite": "^5.4.8",
"vite-plugin-node-polyfills": "^0.22.0"
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^6.3.5",
"vite-plugin-node-polyfills": "^0.23.0"
},
"type": "module",
"dependencies": {
"@docsearch/js": "^3.6.2",
"@internationalized/date": "^3.5.5",
"@docsearch/js": "^3.9.0",
"@internationalized/date": "^3.8.2",
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
"@mapbox/sphericalmercator": "^1.2.0",
"@mapbox/tilebelt": "^1.0.2",
"@mapbox/sphericalmercator": "^2.0.1",
"@mapbox/tilebelt": "^2.0.2",
"@types/mapbox__sphericalmercator": "^1.2.3",
"bits-ui": "^0.21.15",
"chart.js": "^4.4.4",
"chartjs-plugin-zoom": "^2.0.1",
"chart.js": "^4.4.9",
"chartjs-plugin-zoom": "^2.2.0",
"clsx": "^2.1.1",
"dexie": "^4.0.8",
"dexie": "^4.0.11",
"file-saver": "^2.0.5",
"gpx": "file:../gpx",
"immer": "^10.1.1",
"jszip": "^3.10.1",
"lucide-static": "^0.460.0",
"lucide-svelte": "^0.460.1",
"mapbox-gl": "^3.11.1",
"mapbox-gl": "^3.12.0",
"mapillary-js": "^4.1.2",
"mode-watcher": "^0.3.1",
"png.js": "^0.2.1",
"sanitize-html": "^2.13.0",
"sortablejs": "^1.15.3",
"svelte-i18n": "^4.0.0",
"svelte-sonner": "^0.3.28",
"tailwind-merge": "^2.5.2",
"tailwind-variants": "^0.2.1"
"sanitize-html": "^2.17.0",
"sortablejs": "^1.15.6",
"tailwind-merge": "^3.3.0"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

124
website/src/app.css Normal file
View File

@@ -0,0 +1,124 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: hsl(0 0% 100%) /* <- Wrap in HSL */;
--foreground: hsl(240 10% 3.9%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 72.2% 50.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 10% 3.9%);
--sidebar: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--support: rgb(220 15 130);
--link: rgb(0 110 180);
--radius: 0.5rem;
}
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 4.9% 83.9%);
--sidebar: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--support: rgb(255 110 190);
--link: rgb(80 190 255);
}
@theme inline {
/* Radius (for rounded-*) */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* Colors */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring);
--color-radius: var(--radius);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-support: var(--support);
--color-link: var(--link);
--breakpoint-xs: 540px;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,86 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 45%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 92%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 210 40% 98%;
--support: 220 15 130;
--link: 0 110 180;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 30%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--support: 255 110 190;
--link: 80 190 255;
--ring: hsl(212.7, 26.8%, 83.9);
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -41,9 +41,11 @@ export async function handle({ event, resolve }) {
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />
<link rel="manifest" href="/${language}.manifest.webmanifest" />`;
for (let lang of Object.keys(languages)) {
headTag += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${getURLForLanguage(lang, path)}" />
if (page !== '404') {
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, {

View File

@@ -119,6 +119,7 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
},
],
},
utagawaVTT: 'https://maps.utagawavtt.com/styles/utagawavtt/style.json',
swisstopoRaster: {
version: 8,
sources: {
@@ -151,11 +152,12 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
linzTopo: {
type: 'raster',
tiles: [
'https://tiles-cdn.koordinates.com/services;key=39a8b989633a4bef98bc0e065380454a/tiles/v4/layer=50767/EPSG:3857/{z}/{x}/{y}.png',
'https://basemaps.linz.govt.nz/v1/tiles/topo-raster/WebMercatorQuad/{z}/{x}/{y}.webp?api=d01fbtg0ar23gctac5m0jgyy2ds',
],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.linz.govt.nz/" target="_blank">LINZ</a>',
maxzoom: 16,
attribution:
'© <a href="//www.linz.govt.nz/linz-copyright">LINZ CC BY 4.0</a> © <a href="//www.linz.govt.nz/data/linz-data/linz-basemaps/data-attribution">Imagery Basemap contributors</a>',
},
},
layers: [
@@ -185,8 +187,8 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
},
],
},
ignFrPlan: ignFrPlan,
ignFrTopo: ignFrTopo,
ignFrPlan: ignFrPlan as StyleSpecification,
ignFrTopo: ignFrTopo as StyleSpecification,
ignFrScan25: {
version: 8,
sources: {
@@ -208,7 +210,7 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
},
],
},
ignFrSatellite: ignFrSatellite,
ignFrSatellite: ignFrSatellite as StyleSpecification,
ignEs: {
version: 8,
sources: {
@@ -275,68 +277,6 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
},
],
},
swedenTopo: {
version: 8,
sources: {
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: 'swedenTopoWMTS',
type: 'raster',
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: {
version: 8,
sources: {
@@ -427,7 +367,7 @@ export const overlays: { [key: string]: string | StyleSpecification } = {
},
],
},
bikerouterGravel: bikerouterGravel,
bikerouterGravel: bikerouterGravel as StyleSpecification,
swisstopoSlope: {
version: 8,
sources: {
@@ -803,6 +743,7 @@ export const basemapTree: LayerTreeType = {
openTopoMap: true,
openHikingMap: true,
cyclOSM: true,
utagawaVTT: true,
},
countries: {
belgium: {
@@ -831,10 +772,6 @@ export const basemapTree: LayerTreeType = {
ignEs: true,
ignEsSatellite: true,
},
sweden: {
swedenTopo: true,
swedenSatellite: true,
},
switzerland: {
swisstopoRaster: true,
swisstopoVector: true,
@@ -1023,6 +960,7 @@ export const defaultBasemapTree: LayerTreeType = {
openTopoMap: true,
openHikingMap: true,
cyclOSM: true,
utagawaVTT: true,
},
countries: {
belgium: {
@@ -1051,10 +989,6 @@ export const defaultBasemapTree: LayerTreeType = {
ignEs: false,
ignEsSatellite: false,
},
sweden: {
swedenTopo: false,
swedenSatellite: false,
},
switzerland: {
swisstopoRaster: false,
swisstopoVector: false,

View File

@@ -12,7 +12,7 @@ import {
DoorOpen,
Trees,
Fuel,
Home,
House,
Info,
TreeDeciduous,
CircleParking,
@@ -29,7 +29,8 @@ import {
TriangleAlert,
Anchor,
Toilet,
} from 'lucide-svelte';
type IconProps,
} from '@lucide/svelte';
import {
Landmark as LandmarkSvg,
Shell as ShellSvg,
@@ -43,7 +44,7 @@ import {
DoorOpen as DoorOpenSvg,
Trees as TreesSvg,
Fuel as FuelSvg,
Home as HomeSvg,
House as HouseSvg,
Info as InfoSvg,
TreeDeciduous as TreeDeciduousSvg,
CircleParking as CircleParkingSvg,
@@ -61,11 +62,11 @@ import {
Anchor as AnchorSvg,
Toilet as ToiletSvg,
} from 'lucide-static';
import type { ComponentType } from 'svelte';
import type { Component } from 'svelte';
export type Symbol = {
value: string;
icon?: ComponentType<Icon>;
icon?: Component<IconProps>;
iconSvg?: string;
};
@@ -94,7 +95,7 @@ export const symbols: { [key: string]: Symbol } = {
},
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg },
lodge: { value: 'Lodge', icon: Home, iconSvg: HomeSvg },
lodge: { value: 'Lodge', icon: House, iconSvg: HouseSvg },
lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg },
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
@@ -104,7 +105,7 @@ export const symbols: { [key: string]: Symbol } = {
iconSvg: TrainFrontSvg,
},
hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg },
house: { value: 'House', icon: Home, iconSvg: HomeSvg },
house: { value: 'House', icon: House, iconSvg: HouseSvg },
information: { value: 'Information', icon: Info, iconSvg: InfoSvg },
park: { value: 'Park', icon: TreeDeciduous, iconSvg: TreeDeciduousSvg },
parking_area: { value: 'Parking Area', icon: CircleParking, iconSvg: CircleParkingSvg },

View File

@@ -2,7 +2,11 @@
import docsearch from '@docsearch/js';
import '@docsearch/css';
import { onMount } from 'svelte';
import { _, locale, waitLocale } from 'svelte-i18n';
import { i18n } from '$lib/i18n.svelte';
let props: {
class?: string;
} = $props();
let mounted = false;
@@ -13,31 +17,31 @@
indexName: 'gpx',
container: '#docsearch',
searchParameters: {
facetFilters: ['lang:' + ($locale ?? 'en')],
facetFilters: ['lang:' + i18n.lang],
},
placeholder: $_('docs.search.search'),
placeholder: i18n._('docs.search.search'),
disableUserPersonalization: true,
translations: {
button: {
buttonText: $_('docs.search.search'),
buttonAriaLabel: $_('docs.search.search'),
buttonText: i18n._('docs.search.search'),
buttonAriaLabel: i18n._('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'),
resetButtonTitle: i18n._('docs.search.clear'),
resetButtonAriaLabel: i18n._('docs.search.clear'),
cancelButtonText: i18n._('docs.search.cancel'),
cancelButtonAriaLabel: i18n._('docs.search.cancel'),
searchInputLabel: i18n._('docs.search.search'),
},
footer: {
selectText: $_('docs.search.to_select'),
navigateText: $_('docs.search.to_navigate'),
closeText: $_('docs.search.to_close'),
selectText: i18n._('docs.search.to_select'),
navigateText: i18n._('docs.search.to_navigate'),
closeText: i18n._('docs.search.to_close'),
},
noResultsScreen: {
noResultsText: $_('docs.search.no_results'),
suggestedQueryText: $_('docs.search.no_results_suggestion'),
noResultsText: i18n._('docs.search.no_results'),
suggestedQueryText: i18n._('docs.search.no_results_suggestion'),
},
},
},
@@ -48,13 +52,15 @@
mounted = true;
});
$: if (mounted && $locale) {
waitLocale().then(initDocsearch);
}
$effect(() => {
if (mounted && i18n.lang && !i18n.isLoading) {
initDocsearch();
}
});
</script>
<svelte:head>
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
</svelte:head>
<div id="docsearch" {...$$restProps}></div>
<div id="docsearch" class={props.class ?? ''}></div>

View File

@@ -1,28 +1,38 @@
<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';
import type { Snippet } from 'svelte';
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[] = [];
const {
variant = 'default',
label,
side = 'top',
disabled = false,
class: className = '',
children,
onclick,
}: {
variant?: 'default' | 'secondary' | 'link' | 'destructive' | 'outline' | 'ghost';
label: string;
side?: 'top' | 'right' | 'bottom' | 'left';
disabled?: boolean;
class?: string;
children: Snippet;
onclick?: (event: MouseEvent) => void;
} = $props();
</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>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button {...props} {variant} class={className} {onclick}>
{@render children()}
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content {side}>
<span>{label}</span>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>

View File

@@ -1,687 +0,0 @@
<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 Chart from 'chart.js/auto';
import mapboxgl from 'mapbox-gl';
import { map } from '$lib/stores';
import { onDestroy, onMount } from 'svelte';
import {
BrickWall,
TriangleRight,
HeartPulse,
Orbit,
SquareActivity,
Thermometer,
Zap,
Circle,
Check,
ChartNoAxesColumn,
Construction,
} from 'lucide-svelte';
import { getSlopeColor, getSurfaceColor, getHighwayColor } from '$lib/assets/colors';
import { _ } from 'svelte-i18n';
import {
getCadenceWithUnits,
getConvertedDistance,
getConvertedElevation,
getConvertedTemperature,
getConvertedVelocity,
getDistanceUnits,
getDistanceWithUnits,
getElevationWithUnits,
getHeartRateWithUnits,
getPowerWithUnits,
getTemperatureWithUnits,
getVelocityWithUnits,
} from '$lib/units';
import type { Writable } from 'svelte/store';
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 additionalDatasets: string[];
export let elevationFill: 'slope' | 'surface' | 'highway' | undefined;
export let showControls: boolean = true;
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
let canvas: HTMLCanvasElement;
let overlay: HTMLCanvasElement;
let chart: Chart;
Chart.defaults.font.family =
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
let marker: mapboxgl.Marker | null = null;
let dragging = false;
let panning = false;
let options = {
animation: false,
parsing: false,
maintainAspectRatio: false,
scales: {
x: {
type: 'linear',
ticks: {
callback: function (value: number) {
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
},
align: 'inner',
maxRotation: 0,
},
},
y: {
type: 'linear',
ticks: {
callback: function (value: number) {
return getElevationWithUnits(value, false);
},
},
},
},
datasets: {
line: {
pointRadius: 0,
tension: 0.4,
borderWidth: 2,
cubicInterpolationMode: 'monotone',
},
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false,
},
plugins: {
legend: {
display: false,
},
decimation: {
enabled: true,
},
tooltip: {
enabled: () => !dragging && !panning,
callbacks: {
title: function () {
return '';
},
label: function (context: Chart.TooltipContext) {
let point = context.raw;
if (context.datasetIndex === 0) {
if ($map && marker) {
if (dragging) {
marker.remove();
} else {
marker.setLngLat(point.coordinates);
marker.addTo($map);
}
}
return `${$_('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 1) {
return `${$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 2) {
return `${$_('quantities.heartrate')}: ${getHeartRateWithUnits(point.y)}`;
} else if (context.datasetIndex === 3) {
return `${$_('quantities.cadence')}: ${getCadenceWithUnits(point.y)}`;
} else if (context.datasetIndex === 4) {
return `${$_('quantities.temperature')}: ${getTemperatureWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 5) {
return `${$_('quantities.power')}: ${getPowerWithUnits(point.y)}`;
}
},
afterBody: function (contexts: Chart.TooltipContext[]) {
let context = contexts.filter((context) => context.datasetIndex === 0);
if (context.length === 0) return;
let point = context[0].raw;
let slope = {
at: point.slope.at.toFixed(1),
segment: point.slope.segment.toFixed(1),
length: getDistanceWithUnits(point.slope.length),
};
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)}`,
` ${$_('quantities.slope')}: ${slope.at} %${elevationFill === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`,
];
if (elevationFill === 'surface') {
labels.push(
` ${$_('quantities.surface')}: ${$_(`toolbar.routing.surface.${surface}`)}`
);
}
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)}`);
}
return labels;
},
},
},
zoom: {
pan: {
enabled: true,
mode: 'x',
modifierKey: 'shift',
onPanStart: function () {
// hide tooltip
panning = true;
$slicedGPXStatistics = undefined;
},
onPanComplete: function () {
panning = false;
},
},
zoom: {
wheel: {
enabled: true,
},
mode: 'x',
onZoomStart: function ({ chart, event }: { chart: Chart; event: any }) {
if (
event.deltaY < 0 &&
Math.abs(
chart.getInitialScaleBounds().x.max /
chart.options.plugins.zoom.limits.x.minRange -
chart.getZoomLevel()
) < 0.01
) {
// Disable wheel pan if zoomed in to the max, and zooming in
return false;
}
$slicedGPXStatistics = undefined;
},
},
limits: {
x: {
min: 'original',
max: 'original',
minRange: 1,
},
},
},
},
stacked: false,
onResize: function () {
updateOverlay();
},
};
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
datasets.forEach((id) => {
options.scales[`y${id}`] = {
type: 'linear',
position: 'right',
grid: {
display: false,
},
reverse: () => id === 'speed' && $velocityUnits === 'pace',
display: false,
};
});
onMount(async () => {
Chart.register((await import('chartjs-plugin-zoom')).default); // dynamic import to avoid SSR and 'window is not defined' error
chart = new Chart(canvas, {
type: 'line',
data: {
datasets: [],
},
options,
plugins: [
{
id: 'toggleMarker',
events: ['mouseout'],
afterEvent: function (chart: Chart, args: { event: Chart.ChartEvent }) {
if (args.event.type === 'mouseout') {
if ($map && marker) {
marker.remove();
}
}
},
},
],
});
// Map marker to show on hover
let element = document.createElement('div');
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
marker = new mapboxgl.Marker({
element,
});
let startIndex = 0;
let endIndex = 0;
function getIndex(evt) {
const points = chart.getElementsAtEventForMode(
evt,
'x',
{
intersect: false,
},
true
);
if (points.length === 0) {
const rect = canvas.getBoundingClientRect();
if (evt.x - rect.left <= chart.chartArea.left) {
return 0;
} else if (evt.x - rect.left >= chart.chartArea.right) {
return $gpxStatistics.local.points.length - 1;
} else {
return undefined;
}
}
let point = points.find((point) => point.element.raw);
if (point) {
return point.element.raw.index;
} else {
return points[0].index;
}
}
let dragStarted = false;
function onMouseDown(evt) {
if (evt.shiftKey) {
// Panning interaction
return;
}
dragStarted = true;
canvas.style.cursor = 'col-resize';
startIndex = getIndex(evt);
}
function onMouseMove(evt) {
if (dragStarted) {
dragging = true;
endIndex = getIndex(evt);
if (endIndex !== undefined) {
if (startIndex === undefined) {
startIndex = endIndex;
} else if (startIndex !== endIndex) {
$slicedGPXStatistics = [
$gpxStatistics.slice(
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex)
),
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex),
];
}
}
}
}
function onMouseUp(evt) {
dragStarted = false;
dragging = false;
canvas.style.cursor = '';
endIndex = getIndex(evt);
if (startIndex === endIndex) {
$slicedGPXStatistics = undefined;
}
}
canvas.addEventListener('pointerdown', onMouseDown);
canvas.addEventListener('pointermove', onMouseMove);
canvas.addEventListener('pointerup', onMouseUp);
});
$: if (chart && $distanceUnits && $velocityUnits && $temperatureUnits) {
let data = $gpxStatistics;
// update data
chart.data.datasets[0] = {
label: $_('quantities.elevation'),
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.ele ? getConvertedElevation(point.ele) : 0,
time: point.time,
slope: {
at: data.local.slope.at[index],
segment: data.local.slope.segment[index],
length: data.local.slope.length[index],
},
extensions: point.getExtensions(),
coordinates: point.getCoordinates(),
index: index,
};
}),
normalized: true,
fill: 'start',
order: 1,
};
chart.data.datasets[1] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: getConvertedVelocity(data.local.speed[index]),
index: index,
};
}),
normalized: true,
yAxisID: 'yspeed',
hidden: true,
};
chart.data.datasets[2] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getHeartRate(),
index: index,
};
}),
normalized: true,
yAxisID: 'yhr',
hidden: true,
};
chart.data.datasets[3] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getCadence(),
index: index,
};
}),
normalized: true,
yAxisID: 'ycad',
hidden: true,
};
chart.data.datasets[4] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: getConvertedTemperature(point.getTemperature()),
index: index,
};
}),
normalized: true,
yAxisID: 'yatemp',
hidden: true,
};
chart.data.datasets[5] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getPower(),
index: index,
};
}),
normalized: true,
yAxisID: 'ypower',
hidden: true,
};
chart.options.scales.x['min'] = 0;
chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
chart.update();
}
function slopeFillCallback(context) {
return getSlopeColor(context.p0.raw.slope.segment);
}
function surfaceFillCallback(context) {
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) {
if (elevationFill === 'slope') {
chart.data.datasets[0]['segment'] = {
backgroundColor: slopeFillCallback,
};
} else if (elevationFill === 'surface') {
chart.data.datasets[0]['segment'] = {
backgroundColor: surfaceFillCallback,
};
} else if (elevationFill === 'highway') {
chart.data.datasets[0]['segment'] = {
backgroundColor: highwayFillCallback,
};
} else {
chart.data.datasets[0]['segment'] = {};
}
chart.update();
}
$: if (additionalDatasets && chart) {
let includeSpeed = additionalDatasets.includes('speed');
let includeHeartRate = additionalDatasets.includes('hr');
let includeCadence = additionalDatasets.includes('cad');
let includeTemperature = additionalDatasets.includes('atemp');
let includePower = additionalDatasets.includes('power');
if (chart.data.datasets.length > 0) {
chart.data.datasets[1].hidden = !includeSpeed;
chart.data.datasets[2].hidden = !includeHeartRate;
chart.data.datasets[3].hidden = !includeCadence;
chart.data.datasets[4].hidden = !includeTemperature;
chart.data.datasets[5].hidden = !includePower;
}
chart.update();
}
function updateOverlay() {
if (!canvas) {
return;
}
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];
let endIndex = $slicedGPXStatistics[2];
// Draw selection rectangle
let selectionContext = overlay.getContext('2d');
if (selectionContext) {
selectionContext.fillStyle = $mode === 'dark' ? 'white' : 'black';
selectionContext.globalAlpha = $mode === 'dark' ? 0.2 : 0.1;
selectionContext.clearRect(0, 0, overlay.width, overlay.height);
let startPixel = chart.scales.x.getPixelForValue(
getConvertedDistance($gpxStatistics.local.distance.total[startIndex])
);
let endPixel = chart.scales.x.getPixelForValue(
getConvertedDistance($gpxStatistics.local.distance.total[endIndex])
);
selectionContext.fillRect(
startPixel,
chart.chartArea.top,
endPixel - startPixel,
chart.chartArea.height
);
}
} else if (overlay) {
let selectionContext = overlay.getContext('2d');
if (selectionContext) {
selectionContext.clearRect(0, 0, overlay.width, overlay.height);
}
}
}
$: $slicedGPXStatistics, $mode, updateOverlay();
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<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="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"
>
<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

@@ -2,8 +2,8 @@
import { Button } from '$lib/components/ui/button';
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
import Logo from '$lib/components/Logo.svelte';
import { AtSign, BookOpenText, Heart, Home, Map } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n';
import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils';
</script>
@@ -14,109 +14,109 @@
<Logo class="h-8" width="153" />
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
target="_blank"
>
MIT © 2024 gpx.studio
MIT © 2025 gpx.studio
</Button>
<LanguageSelect class="w-40 mt-3" />
</div>
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="flex flex-col items-start gap-1">
<span class="font-semibold">{$_('homepage.website')}</span>
<span class="font-semibold">{i18n._('homepage.website')}</span>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage($locale, '/')}
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/')}
>
<Home size="16" class="mr-1" />
{$_('homepage.home')}
<House size="16" />
{i18n._('homepage.home')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage($locale, '/app')}
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/app')}
>
<Map size="16" class="mr-1" />
{$_('homepage.app')}
<Map size="16" />
{i18n._('homepage.app')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage($locale, '/help')}
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/help')}
>
<BookOpenText size="16" class="mr-1" />
{$_('menu.help')}
<BookOpenText size="16" />
{i18n._('menu.help')}
</Button>
</div>
<div class="flex flex-col items-start gap-1" id="contact">
<span class="font-semibold">{$_('homepage.contact')}</span>
<span class="font-semibold">{i18n._('homepage.contact')}</span>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://www.reddit.com/r/gpxstudio/"
target="_blank"
>
<Logo company="reddit" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.reddit')}
<Logo company="reddit" class="h-4 fill-muted-foreground" />
{i18n._('homepage.reddit')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://facebook.com/gpx.studio"
target="_blank"
>
<Logo company="facebook" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.facebook')}
<Logo company="facebook" class="h-4 fill-muted-foreground" />
{i18n._('homepage.facebook')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://x.com/gpxstudio"
target="_blank"
>
<Logo company="x" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.x')}
<Logo company="x" class="h-4 fill-muted-foreground" />
{i18n._('homepage.x')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="mailto:hello@gpx.studio"
target="_blank"
>
<AtSign size="16" class="mr-1" />
{$_('homepage.email')}
<AtSign size="16" />
{i18n._('homepage.email')}
</Button>
</div>
<div class="flex flex-col items-start gap-1">
<span class="font-semibold">{$_('homepage.contribute')}</span>
<span class="font-semibold">{i18n._('homepage.contribute')}</span>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://ko-fi.com/gpxstudio"
target="_blank"
>
<Heart size="16" class="mr-1" />
{$_('menu.donate')}
<Heart size="16" />
{i18n._('menu.donate')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://crowdin.com/project/gpxstudio"
target="_blank"
>
<Logo company="crowdin" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.crowdin')}
<Logo company="crowdin" class="h-4 fill-muted-foreground" />
{i18n._('homepage.crowdin')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio"
target="_blank"
>
<Logo company="github" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.github')}
<Logo company="github" class="h-4 fill-muted-foreground" />
{i18n._('homepage.github')}
</Button>
</div>
</div>

View File

@@ -3,46 +3,49 @@
import Tooltip from '$lib/components/Tooltip.svelte';
import WithUnits from '$lib/components/WithUnits.svelte';
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from 'lucide-svelte';
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
import { _ } from 'svelte-i18n';
import { i18n } from '$lib/i18n.svelte';
import type { GPXStatistics } from 'gpx';
import type { Writable } from 'svelte/store';
import { settings } from '$lib/db';
export let gpxStatistics: Writable<GPXStatistics>;
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
export let orientation: 'horizontal' | 'vertical';
export let panelSize: number;
import type { Readable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
const { velocityUnits } = settings;
let statistics: GPXStatistics;
let {
gpxStatistics,
slicedGPXStatistics,
orientation,
panelSize,
}: {
gpxStatistics: Readable<GPXStatistics>;
slicedGPXStatistics: Readable<[GPXStatistics, number, number] | undefined>;
orientation: 'horizontal' | 'vertical';
panelSize: number;
} = $props();
$: if ($slicedGPXStatistics !== undefined) {
statistics = $slicedGPXStatistics[0];
} else {
statistics = $gpxStatistics;
}
let statistics = $derived(
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics
);
</script>
<Card.Root
class="h-full {orientation === 'vertical'
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
: 'w-full'} border-none shadow-none"
: 'w-full'} border-none shadow-none p-0"
>
<Card.Content
class="h-full flex {orientation === 'vertical'
? 'flex-col justify-center'
: 'flex-row w-full justify-between'} gap-4 p-0"
>
<Tooltip label={$_('quantities.distance')}>
<Tooltip label={i18n._('quantities.distance')}>
<span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" />
<WithUnits value={statistics.global.distance.total} type="distance" />
</span>
</Tooltip>
<Tooltip label={$_('quantities.elevation_gain_loss')}>
<Tooltip label={i18n._('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" />
@@ -54,8 +57,10 @@
<Tooltip
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
label="{$velocityUnits === 'speed'
? $_('quantities.speed')
: $_('quantities.pace')} ({$_('quantities.moving')} / {$_('quantities.total')})"
? i18n._('quantities.speed')
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center">
<Zap size="16" class="mr-1" />
@@ -72,7 +77,7 @@
{#if panelSize > 160 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_(
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>

View File

@@ -1,19 +1,26 @@
<script lang="ts">
import { CircleHelp } from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import { CircleQuestionMark } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import type { Snippet } from 'svelte';
export let link: string | undefined = undefined;
let {
link,
class: className = '',
children,
}: {
link: string;
class?: string;
children: Snippet;
} = $props();
</script>
<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 class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {className}">
<CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" />
<div>
<slot />
{@render children()}
{#if link}
<a href={link} target="_blank" class="text-sm text-link hover:underline">
{$_('menu.more')}
{i18n._('menu.more')}
</a>
{/if}
</div>

View File

@@ -1,51 +1,36 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import * as Select from '$lib/components/ui/select';
import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils';
import { Languages } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n';
import { Languages } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
let selected = {
value: '',
label: '',
};
$: if ($locale) {
selected = {
value: $locale,
label: languages[$locale],
};
}
let {
class: className = '',
}: {
class?: string;
} = $props();
</script>
<Select.Root bind:selected>
<Select.Trigger class="w-[180px] {$$props.class ?? ''}" aria-label={$_('menu.language')}>
<Select.Root type="single" value={i18n.lang}>
<Select.Trigger class="min-w-[180px] {className}" aria-label={i18n._('menu.language')}>
<Languages size="16" />
<Select.Value class="ml-2 mr-auto" />
<span class="mr-auto">
{languages[i18n.lang]}
</span>
</Select.Trigger>
<Select.Content>
{#each Object.entries(languages) as [lang, label]}
{#if $page.url.pathname.includes('404')}
{#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)}>
<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">
{#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

@@ -1,31 +1,36 @@
<script lang="ts">
import { mode } from 'mode-watcher';
import { base } from '$app/paths';
import { mode, systemPrefersMode } from 'mode-watcher';
export let iconOnly = false;
export let company = 'gpx.studio';
$: effectiveMode = $mode ?? $systemPrefersMode ?? 'light';
let {
iconOnly = false,
company = 'gpx.studio',
...others
}: {
iconOnly?: boolean;
company?: 'gpx.studio' | 'mapbox' | 'github' | 'crowdin' | 'facebook' | 'x' | 'reddit';
[key: string]: any;
} = $props();
</script>
{#if company === 'gpx.studio'}
<img
src="{base}/{iconOnly ? 'icon' : 'logo'}{effectiveMode === 'dark' ? '-dark' : ''}.svg"
src="{base}/{iconOnly ? 'icon' : 'logo'}{mode.current === 'dark' ? '-dark' : ''}.svg"
alt="Logo of gpx.studio."
{...$$restProps}
{...others}
/>
{:else if company === 'mapbox'}
<img
src="{base}/mapbox-logo-{effectiveMode === 'dark' ? 'white' : 'black'}.svg"
src="{base}/mapbox-logo-{mode.current === 'dark' ? 'white' : 'black'}.svg"
alt="Logo of Mapbox."
{...$$restProps}
{...others}
/>
{:else if company === 'github'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
class="fill-foreground {others.class ?? ''}"
><title>GitHub</title><path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/></svg
@@ -35,7 +40,7 @@
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
class="fill-foreground {others.class ?? ''}"
><title>Crowdin</title><path
d="M16.119 17.793a2.619 2.619 0 0 1-1.667-.562c-.546-.436-1.004-1.09-1.018-1.858-.008-.388.414-.388.414-.388l1.018-.008c.332.008.43.47.445.586.128 1.04.717 1.495 1.168 1.702.273.123.204.513-.362.528zm-5.695-5.287L8.5 12.252c-.867-.214-.844-.982-.807-1.247a5.119 5.119 0 0 1 .814-2.125c.545-.804 1.303-1.508 2.29-2.073 1.856-1.074 4.45-1.673 7.31-1.673 2.09 0 4.256.27 4.29.27.197.025.328.213.333.437a.377.377 0 0 1-.355.393l-.92-.01c-2.902 0-4.968.394-6.506 1.248-1.527.837-2.57 2.117-3.287 4.012-.076.163-.335 1.12-1.24 1.022zm2.533 7.823c-1.44 0-2.797-.622-3.825-1.746-.87-.96-1.397-1.931-1.493-3.164-.06-.813.3-1.094.788-1.044l1.988.218c.45.092.75.34.825.854.397 2.736 2.122 3.814 3.15 4.046.18.042.292.157.283.365a.412.412 0 0 1-.322.398c-.458.074-.936.073-1.394.073zm-4.101 2.418a14.216 14.216 0 0 1-2.307-.214c-1.202-.214-2.208-.582-3.072-1.13C1.41 20.095.163 17.786.014 15.048c-.037-.65-.11-1.89 1.427-1.797.638.033 1.653.343 2.368.548.887.247 1.314.933 1.314 1.608 0 3.858 3.494 6.408 5.02 6.408.654 0 .414.701.127.779-.502.136-1.15.153-1.413.153zM3.525 11.419c-.605-.109-1.194-.358-1.768-.5C-.018 10.479.284 8.688.45 8.196c1.617-4.757 6.746-6.35 10.887-6.773 3.898-.4 7.978-.092 11.778.967.31.083 1.269.327.718.891-.35.358-1.7-.016-2.073-.041-2.23-.167-4.434-.192-6.656.15-2.349.357-4.768 1.099-6.71 2.665-.938.758-1.76 1.723-2.313 2.866-.144.3-.256.6-.354.9-.11.327-.47 1.91-2.215 1.6zm9.94.917c.332-1.488 1.81-3.848 6.385-3.686 1.05.033.57.749.052.731-2.586-.09-3.815 1.578-4.457 3.27-.219.546-.68.626-1.271.53-.415-.074-.866-.123-.71-.846Z"
/></svg
@@ -45,7 +50,7 @@
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
class="fill-foreground {others.class ?? ''}"
><title>Facebook</title><path
d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
/></svg
@@ -55,7 +60,7 @@
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
class="fill-foreground {others.class ?? ''}"
><title>X</title><path
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
/></svg
@@ -65,7 +70,7 @@
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
class="fill-foreground {others.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

View File

@@ -1,393 +0,0 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
import { Button } from '$lib/components/ui/button';
import { map } from '$lib/stores';
import { settings } from '$lib/db';
import { _ } from 'svelte-i18n';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { page } from '$app/stores';
export let accessToken = PUBLIC_MAPBOX_TOKEN;
export let geolocate = true;
export let geocoder = true;
export let hash = true;
mapboxgl.accessToken = accessToken;
let webgl2Supported = true;
let embeddedApp = false;
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
maxZoom: 15,
linear: true,
easing: () => 1,
};
const { distanceUnits, elevationProfile, treeFileView, bottomPanelSize, rightPanelSize } =
settings;
let scaleControl = new mapboxgl.ScaleControl({
unit: $distanceUnits,
});
onMount(() => {
let gl = document.createElement('canvas').getContext('webgl2');
if (!gl) {
webgl2Supported = false;
return;
}
if (window.top !== window.self && !$page.route.id?.includes('embed')) {
embeddedApp = true;
return;
}
let language = $page.params.language;
if (language === 'zh') {
language = 'zh-Hans';
} else if (language?.includes('-')) {
language = language.split('-')[0];
} else if (language === '' || language === undefined) {
language = 'en';
}
let newMap = new mapboxgl.Map({
container: 'map',
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: [],
},
},
],
},
projection: 'globe',
zoom: 0,
hash: hash,
language,
attributionControl: false,
logoPosition: 'bottom-right',
boxZoom: false,
});
newMap.on('load', () => {
$map = newMap; // only set the store after the map has loaded
window._map = newMap; // entry point for extensions
scaleControl.setUnit($distanceUnits);
});
newMap.addControl(
new mapboxgl.AttributionControl({
compact: true,
})
);
newMap.addControl(
new mapboxgl.NavigationControl({
visualizePitch: true,
})
);
if (geocoder) {
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) {
newMap.addControl(
new mapboxgl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true,
},
fitBoundsOptions,
trackUserLocation: true,
showUserHeading: true,
})
);
}
newMap.addControl(scaleControl);
newMap.on('style.load', () => {
newMap.addSource('mapbox-dem', {
type: 'raster-dem',
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
tileSize: 512,
maxzoom: 14,
});
if (newMap.getPitch() > 0) {
newMap.setTerrain({
source: 'mapbox-dem',
exaggeration: 1,
});
}
newMap.setFog({
color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)',
'horizon-blend': 0.1,
'space-color': 'rgb(156, 240, 255)',
});
newMap.on('pitch', () => {
if (newMap.getPitch() > 0) {
newMap.setTerrain({
source: 'mapbox-dem',
exaggeration: 1,
});
} else {
newMap.setTerrain(null);
}
});
});
});
onDestroy(() => {
if ($map) {
$map.remove();
$map = null;
}
});
$: if ($map && (!$treeFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)) {
$map.resize();
}
</script>
<div {...$$restProps}>
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
<div
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported &&
!embeddedApp
? 'hidden'
: ''} {embeddedApp ? 'z-30' : ''}"
>
{#if !webgl2Supported}
<p>{$_('webgl2_required')}</p>
<Button href="https://get.webgl.org/webgl2/" target="_blank">
{$_('enable_webgl2')}
</Button>
{:else if embeddedApp}
<p>The app cannot be embedded in an iframe.</p>
<Button href="https://gpx.studio/help/integration" target="_blank">
Learn how to create a map for your website
</Button>
{/if}
</div>
</div>
<style lang="postcss">
div :global(.mapboxgl-map) {
@apply font-sans;
}
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
@apply shadow-md;
@apply bg-background;
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-icon) {
@apply dark:brightness-[4.7];
}
div :global(.mapboxgl-ctrl-geocoder) {
@apply flex;
@apply flex-row;
@apply w-fit;
@apply min-w-fit;
@apply items-center;
@apply shadow-md;
}
div :global(.suggestions) {
@apply shadow-md;
@apply bg-background;
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
@apply text-foreground;
@apply hover:text-accent-foreground;
@apply hover:bg-accent;
}
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
@apply bg-background;
}
div :global(.mapboxgl-ctrl-geocoder--button) {
@apply bg-transparent;
@apply hover:bg-transparent;
}
div :global(.mapboxgl-ctrl-geocoder--icon) {
@apply fill-foreground;
@apply hover:fill-accent-foreground;
}
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
@apply relative;
@apply top-0;
@apply left-0;
@apply my-2;
@apply w-[29px];
}
div :global(.mapboxgl-ctrl-geocoder--input) {
@apply relative;
@apply w-64;
@apply py-0;
@apply pl-2;
@apply focus:outline-none;
@apply transition-[width];
@apply duration-200;
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
@apply w-0;
@apply p-0;
}
div :global(.mapboxgl-ctrl-top-right) {
@apply z-40;
@apply flex;
@apply flex-col;
@apply items-end;
@apply h-full;
@apply overflow-hidden;
}
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
@apply bottom-[42px];
}
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
@apply bottom-[42px];
}
div :global(.mapboxgl-ctrl-attrib) {
@apply dark:bg-transparent;
}
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
@apply dark:bg-background;
}
div :global(.mapboxgl-ctrl-attrib-button) {
@apply dark:bg-foreground;
}
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
@apply dark:bg-foreground;
}
div :global(.mapboxgl-ctrl-attrib a) {
@apply text-foreground;
}
div :global(.mapboxgl-popup) {
@apply w-fit;
@apply z-50;
}
div :global(.mapboxgl-popup-content) {
@apply p-0;
@apply bg-transparent;
@apply shadow-none;
}
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
@apply border-r-background;
}
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
@apply border-l-background;
}
</style>

View File

@@ -1,25 +0,0 @@
<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

@@ -43,40 +43,35 @@
BookOpenText,
ChartArea,
Maximize,
} from 'lucide-svelte';
import {
map,
triggerFileInput,
createFile,
loadFiles,
updateSelectionFromKey,
allHidden,
editMetadata,
editStyle,
exportState,
ExportState,
centerMapOnSelection,
} from '$lib/stores';
import {
copied,
copySelection,
cutSelection,
pasteSelection,
selectAll,
selection,
} from '$lib/components/file-list/Selection';
import { derived } from 'svelte/store';
import { canUndo, canRedo, dbUtils, fileObservers, settings } from '$lib/db';
import { anySelectedLayer } from '$lib/components/layer-control/utils';
} from '@lucide/svelte';
import { map } from '$lib/components/map/map';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { exportState, ExportState } from '$lib/components/export/utils.svelte';
import { anySelectedLayer } from '$lib/components/map/layer-control/utils';
import { defaultOverlays } from '$lib/assets/layers';
import LayerControlSettings from '$lib/components/layer-control/LayerControlSettings.svelte';
import { allowedPastes, ListFileItem, ListTrackItem } from '$lib/components/file-list/FileList';
import Export from '$lib/components/Export.svelte';
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
import { _, locale } from 'svelte-i18n';
import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte';
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/file-list';
import Export from '$lib/components/export/Export.svelte';
import { mode, setMode } from 'mode-watcher';
import { i18n } from '$lib/i18n.svelte';
import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils';
import { settings } from '$lib/logic/settings';
import {
createFile,
fileActions,
loadFiles,
pasteSelection,
triggerFileInput,
} from '$lib/logic/file-actions';
import { fileStateCollection } from '$lib/logic/file-state';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { copied, selection } from '$lib/logic/selection';
import { allHidden } from '$lib/logic/hidden';
import { boundsManager } from '$lib/logic/bounds';
import { tick } from 'svelte';
import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
const {
distanceUnits,
@@ -94,121 +89,109 @@
routing,
} = settings;
let undoDisabled = derived(canUndo, ($canUndo) => !$canUndo);
let redoDisabled = derived(canRedo, ($canRedo) => !$canRedo);
const canUndo = fileActionManager.canUndo;
const canRedo = fileActionManager.canRedo;
function switchBasemaps() {
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
}
function toggleOverlays() {
if (anySelectedLayer($currentOverlays)) {
if ($currentOverlays && anySelectedLayer($currentOverlays)) {
[$currentOverlays, $previousOverlays] = [defaultOverlays, $currentOverlays];
} else {
[$currentOverlays, $previousOverlays] = [$previousOverlays, defaultOverlays];
}
}
function toggle3D() {
if ($map) {
if ($map.getPitch() === 0) {
$map.easeTo({ pitch: 70 });
} else {
$map.easeTo({ pitch: 0 });
}
}
}
let layerSettingsOpen = false;
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
let layerSettingsOpen = $state(false);
</script>
<div class="absolute md:top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none">
<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={getURLForLanguage($locale, '/')} target="_blank" class="shrink-0">
<a href={getURLForLanguage(i18n.lang, '/')} 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.Root class="border-none shadow-none h-fit p-0">
<Menubar.Menu>
<Menubar.Trigger aria-label={$_('gpx.file')}>
<Menubar.Trigger aria-label={i18n._('gpx.file')}>
<File size="18" class="md:hidden" />
<span class="hidden md:block">{$_('gpx.file')}</span>
<span class="hidden md:block">{i18n._('gpx.file')}</span>
</Menubar.Trigger>
<Menubar.Content class="border-none">
<Menubar.Item on:click={createFile}>
<Plus size="16" class="mr-1" />
{$_('menu.new')}
<Menubar.Item onclick={createFile}>
<Plus size="16" />
{i18n._('menu.new')}
<Shortcut key="+" ctrl={true} />
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item on:click={triggerFileInput}>
<FolderOpen size="16" class="mr-1" />
{$_('menu.open')}
<Menubar.Item onclick={triggerFileInput}>
<FolderOpen size="16" />
{i18n._('menu.open')}
<Shortcut key="O" ctrl={true} />
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item
on:click={dbUtils.duplicateSelection}
onclick={fileActions.duplicateSelection}
disabled={$selection.size == 0}
>
<Copy size="16" class="mr-1" />
{$_('menu.duplicate')}
<Copy size="16" />
{i18n._('menu.duplicate')}
<Shortcut key="D" ctrl={true} />
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item
on:click={dbUtils.deleteSelectedFiles}
onclick={() => tick().then(fileActions.deleteSelectedFiles)}
disabled={$selection.size == 0}
>
<FileX size="16" class="mr-1" />
{$_('menu.close')}
<FileX size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</Menubar.Item>
<Menubar.Item
on:click={dbUtils.deleteAllFiles}
disabled={$fileObservers.size == 0}
onclick={fileActions.deleteAllFiles}
disabled={fileStateCollection.size == 0}
>
<FileX size="16" class="mr-1" />
{$_('menu.close_all')}
<FileX size="16" />
{i18n._('menu.delete_all')}
<Shortcut key="⌫" ctrl={true} shift={true} />
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item
on:click={() => ($exportState = ExportState.SELECTION)}
onclick={() => (exportState.current = ExportState.SELECTION)}
disabled={$selection.size == 0}
>
<Download size="16" class="mr-1" />
{$_('menu.export')}
<Download size="16" />
{i18n._('menu.export')}
<Shortcut key="S" ctrl={true} />
</Menubar.Item>
<Menubar.Item
on:click={() => ($exportState = ExportState.ALL)}
disabled={$fileObservers.size == 0}
onclick={() => (exportState.current = ExportState.ALL)}
disabled={fileStateCollection.size == 0}
>
<Download size="16" class="mr-1" />
{$_('menu.export_all')}
<Download size="16" />
{i18n._('menu.export_all')}
<Shortcut key="S" ctrl={true} shift={true} />
</Menubar.Item>
</Menubar.Content>
</Menubar.Menu>
<Menubar.Menu>
<Menubar.Trigger aria-label={$_('menu.edit')}>
<Menubar.Trigger aria-label={i18n._('menu.edit')}>
<FilePen size="18" class="md:hidden" />
<span class="hidden md:block">{$_('menu.edit')}</span>
<span class="hidden md:block">{i18n._('menu.edit')}</span>
</Menubar.Trigger>
<Menubar.Content class="border-none">
<Menubar.Item on:click={dbUtils.undo} disabled={$undoDisabled}>
<Undo2 size="16" class="mr-1" />
{$_('menu.undo')}
<Menubar.Item onclick={() => fileActionManager.undo()} disabled={!$canUndo}>
<Undo2 size="16" />
{i18n._('menu.undo')}
<Shortcut key="Z" ctrl={true} />
</Menubar.Item>
<Menubar.Item on:click={dbUtils.redo} disabled={$redoDisabled}>
<Redo2 size="16" class="mr-1" />
{$_('menu.redo')}
<Menubar.Item onclick={() => fileActionManager.redo()} disabled={!$canRedo}>
<Redo2 size="16" />
{i18n._('menu.redo')}
<Shortcut key="Z" ctrl={true} shift={true} />
</Menubar.Item>
<Menubar.Separator />
@@ -221,10 +204,10 @@
item instanceof ListFileItem ||
item instanceof ListTrackItem
)}
on:click={() => ($editMetadata = true)}
onclick={() => (editMetadata.current = true)}
>
<Info size="16" class="mr-1" />
{$_('menu.metadata.button')}
<Info size="16" />
{i18n._('menu.metadata.button')}
<Shortcut key="I" ctrl={true} />
</Menubar.Item>
<Menubar.Item
@@ -236,27 +219,27 @@
item instanceof ListFileItem ||
item instanceof ListTrackItem
)}
on:click={() => ($editStyle = true)}
onclick={() => (editStyle.current = true)}
>
<PaintBucket size="16" class="mr-1" />
{$_('menu.style.button')}
<PaintBucket size="16" />
{i18n._('menu.style.button')}
</Menubar.Item>
<Menubar.Item
on:click={() => {
onclick={() => {
if ($allHidden) {
dbUtils.setHiddenToSelection(false);
fileActions.setHiddenToSelection(false);
} else {
dbUtils.setHiddenToSelection(true);
fileActions.setHiddenToSelection(true);
}
}}
disabled={$selection.size == 0}
>
{#if $allHidden}
<Eye size="16" class="mr-1" />
{$_('menu.unhide')}
<Eye size="16" />
{i18n._('menu.unhide')}
{:else}
<EyeOff size="16" class="mr-1" />
{$_('menu.hide')}
<EyeOff size="16" />
{i18n._('menu.hide')}
{/if}
<Shortcut key="H" ctrl={true} />
</Menubar.Item>
@@ -264,56 +247,71 @@
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
<Menubar.Separator />
<Menubar.Item
on:click={() =>
dbUtils.addNewTrack($selection.getSelected()[0].getFileId())}
onclick={() =>
fileActions.addNewTrack(
$selection.getSelected()[0].getFileId()
)}
disabled={$selection.size !== 1}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_track')}
<Plus size="16" />
{i18n._('menu.new_track')}
</Menubar.Item>
{:else if $selection
.getSelected()
.some((item) => item instanceof ListTrackItem)}
<Menubar.Separator />
<Menubar.Item
on:click={() => {
onclick={() => {
let item = $selection.getSelected()[0];
dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex());
fileActions.addNewSegment(
item.getFileId(),
item.getTrackIndex()
);
}}
disabled={$selection.size !== 1}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_segment')}
<Plus size="16" />
{i18n._('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')}
<Menubar.Item
onclick={() => selection.selectAll()}
disabled={fileStateCollection.size == 0}
>
<FileStack size="16" />
{i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</Menubar.Item>
<Menubar.Item
on:click={() => {
onclick={() => {
if ($selection.size > 0) {
centerMapOnSelection();
boundsManager.centerMapOnSelection();
}
}}
disabled={$selection.size == 0}
>
<Maximize size="16" class="mr-1" />
{$_('menu.center')}
<Maximize size="16" />
{i18n._('menu.center')}
<Shortcut key="⏎" ctrl={true} />
</Menubar.Item>
{#if $treeFileView}
<Menubar.Separator />
<Menubar.Item on:click={copySelection} disabled={$selection.size === 0}>
<ClipboardCopy size="16" class="mr-1" />
{$_('menu.copy')}
<Menubar.Item
onclick={() => selection.copySelection()}
disabled={$selection.size === 0}
>
<ClipboardCopy size="16" />
{i18n._('menu.copy')}
<Shortcut key="C" ctrl={true} />
</Menubar.Item>
<Menubar.Item on:click={cutSelection} disabled={$selection.size === 0}>
<Scissors size="16" class="mr-1" />
{$_('menu.cut')}
<Menubar.Item
onclick={() => selection.cutSelection()}
disabled={$selection.size === 0}
>
<Scissors size="16" />
{i18n._('menu.cut')}
<Shortcut key="X" ctrl={true} />
</Menubar.Item>
<Menubar.Item
@@ -321,124 +319,118 @@
$copied.length === 0 ||
($selection.size > 0 &&
!allowedPastes[$copied[0].level].includes(
$selection.getSelected().pop()?.level
$selection.getSelected().pop()!.level
))}
on:click={pasteSelection}
onclick={pasteSelection}
>
<ClipboardPaste size="16" class="mr-1" />
{$_('menu.paste')}
<ClipboardPaste size="16" />
{i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} />
</Menubar.Item>
{/if}
<Menubar.Separator />
<Menubar.Item
on:click={dbUtils.deleteSelection}
onclick={() => tick().then(fileActions.deleteSelection)}
disabled={$selection.size == 0}
>
<Trash2 size="16" class="mr-1" />
{$_('menu.delete')}
<Trash2 size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</Menubar.Item>
</Menubar.Content>
</Menubar.Menu>
<Menubar.Menu>
<Menubar.Trigger aria-label={$_('menu.view')}>
<Menubar.Trigger aria-label={i18n._('menu.view')}>
<View size="18" class="md:hidden" />
<span class="hidden md:block">{$_('menu.view')}</span>
<span class="hidden md:block">{i18n._('menu.view')}</span>
</Menubar.Trigger>
<Menubar.Content class="border-none">
<Menubar.CheckboxItem bind:checked={$elevationProfile}>
<ChartArea size="16" class="mr-1" />
{$_('menu.elevation_profile')}
<ChartArea size="16" />
{i18n._('menu.elevation_profile')}
<Shortcut key="P" ctrl={true} />
</Menubar.CheckboxItem>
<Menubar.CheckboxItem bind:checked={$treeFileView}>
<ListTree size="16" class="mr-1" />
{$_('menu.tree_file_view')}
<ListTree size="16" />
{i18n._('menu.tree_file_view')}
<Shortcut key="L" ctrl={true} />
</Menubar.CheckboxItem>
<Menubar.Separator />
<Menubar.Item inset on:click={switchBasemaps}>
<Map size="16" class="mr-1" />{$_('menu.switch_basemap')}<Shortcut
key="F1"
/>
<Menubar.Item inset onclick={switchBasemaps}>
<Map size="16" />{i18n._('menu.switch_basemap')}<Shortcut key="F1" />
</Menubar.Item>
<Menubar.Item inset on:click={toggleOverlays}>
<Layers2 size="16" class="mr-1" />{$_('menu.toggle_overlays')}<Shortcut
key="F2"
/>
<Menubar.Item inset onclick={toggleOverlays}>
<Layers2 size="16" />{i18n._('menu.toggle_overlays')}<Shortcut key="F2" />
</Menubar.Item>
<Menubar.Separator />
<Menubar.CheckboxItem bind:checked={$distanceMarkers}>
<Coins size="16" class="mr-1" />{$_('menu.distance_markers')}<Shortcut
key="F3"
/>
<Coins size="16" />{i18n._('menu.distance_markers')}<Shortcut key="F3" />
</Menubar.CheckboxItem>
<Menubar.CheckboxItem bind:checked={$directionMarkers}>
<Milestone size="16" class="mr-1" />{$_('menu.direction_markers')}<Shortcut
<Milestone size="16" />{i18n._('menu.direction_markers')}<Shortcut
key="F4"
/>
</Menubar.CheckboxItem>
<Menubar.Separator />
<Menubar.Item inset on:click={toggle3D}>
<Box size="16" class="mr-1" />
{$_('menu.toggle_3d')}
<Shortcut key="{$_('menu.ctrl')}+{$_('menu.drag')}" />
<Menubar.Item inset onclick={() => map.toggle3D()}>
<Box size="16" />
{i18n._('menu.toggle_3d')}
<Shortcut key="{i18n._('menu.ctrl')} {i18n._('menu.drag')}" />
</Menubar.Item>
</Menubar.Content>
</Menubar.Menu>
<Menubar.Menu>
<Menubar.Trigger aria-label={$_('menu.settings')}>
<Menubar.Trigger aria-label={i18n._('menu.settings')}>
<Settings size="18" class="md:hidden" />
<span class="hidden md:block">
{$_('menu.settings')}
{i18n._('menu.settings')}
</span>
</Menubar.Trigger>
<Menubar.Content class="border-none">
<Menubar.Sub>
<Menubar.SubTrigger>
<Ruler size="16" class="mr-1" />{$_('menu.distance_units')}
<Ruler size="16" class="mr-2" />{i18n._('menu.distance_units')}
</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.RadioGroup bind:value={$distanceUnits}>
<Menubar.RadioItem value="metric"
>{$_('menu.metric')}</Menubar.RadioItem
>{i18n._('menu.metric')}</Menubar.RadioItem
>
<Menubar.RadioItem value="imperial"
>{$_('menu.imperial')}</Menubar.RadioItem
>{i18n._('menu.imperial')}</Menubar.RadioItem
>
<Menubar.RadioItem value="nautical"
>{$_('menu.nautical')}</Menubar.RadioItem
>{i18n._('menu.nautical')}</Menubar.RadioItem
>
</Menubar.RadioGroup>
</Menubar.SubContent>
</Menubar.Sub>
<Menubar.Sub>
<Menubar.SubTrigger>
<Zap size="16" class="mr-1" />{$_('menu.velocity_units')}
<Zap size="16" class="mr-2" />{i18n._('menu.velocity_units')}
</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.RadioGroup bind:value={$velocityUnits}>
<Menubar.RadioItem value="speed"
>{$_('quantities.speed')}</Menubar.RadioItem
>{i18n._('quantities.speed')}</Menubar.RadioItem
>
<Menubar.RadioItem value="pace"
>{$_('quantities.pace')}</Menubar.RadioItem
>{i18n._('quantities.pace')}</Menubar.RadioItem
>
</Menubar.RadioGroup>
</Menubar.SubContent>
</Menubar.Sub>
<Menubar.Sub>
<Menubar.SubTrigger>
<Thermometer size="16" class="mr-1" />{$_('menu.temperature_units')}
<Thermometer size="16" class="mr-2" />{i18n._('menu.temperature_units')}
</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.RadioGroup bind:value={$temperatureUnits}>
<Menubar.RadioItem value="celsius"
>{$_('menu.celsius')}</Menubar.RadioItem
>{i18n._('menu.celsius')}</Menubar.RadioItem
>
<Menubar.RadioItem value="fahrenheit"
>{$_('menu.fahrenheit')}</Menubar.RadioItem
>{i18n._('menu.fahrenheit')}</Menubar.RadioItem
>
</Menubar.RadioGroup>
</Menubar.SubContent>
@@ -446,11 +438,11 @@
<Menubar.Separator />
<Menubar.Sub>
<Menubar.SubTrigger>
<Languages size="16" class="mr-1" />
{$_('menu.language')}
<Languages size="16" class="mr-2" />
{i18n._('menu.language')}
</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.RadioGroup bind:value={$locale}>
<Menubar.RadioGroup value={i18n.lang}>
{#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang, '/app')}>
<Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem>
@@ -461,24 +453,25 @@
</Menubar.Sub>
<Menubar.Sub>
<Menubar.SubTrigger>
{#if selectedMode === 'light'}
<Sun size="16" class="mr-1" />
{#if mode.current === 'light' || !mode.current}
<Sun size="16" class="mr-2" />
{:else}
<Moon size="16" class="mr-1" />
<Moon size="16" class="mr-2" />
{/if}
{$_('menu.mode')}
{i18n._('menu.mode')}
</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.RadioGroup
bind:value={selectedMode}
value={mode.current ?? 'light'}
onValueChange={(value) => {
setMode(value);
setMode(value as 'light' | 'dark');
}}
>
<Menubar.RadioItem value="light"
>{$_('menu.light')}</Menubar.RadioItem
>{i18n._('menu.light')}</Menubar.RadioItem
>
<Menubar.RadioItem value="dark">{$_('menu.dark')}</Menubar.RadioItem
<Menubar.RadioItem value="dark"
>{i18n._('menu.dark')}</Menubar.RadioItem
>
</Menubar.RadioGroup>
</Menubar.SubContent>
@@ -486,23 +479,23 @@
<Menubar.Separator />
<Menubar.Sub>
<Menubar.SubTrigger>
<PersonStanding size="16" class="mr-1" />
{$_('menu.street_view_source')}
<PersonStanding size="16" class="mr-2" />
{i18n._('menu.street_view_source')}
</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.RadioGroup bind:value={$streetViewSource}>
<Menubar.RadioItem value="mapillary"
>{$_('menu.mapillary')}</Menubar.RadioItem
>{i18n._('menu.mapillary')}</Menubar.RadioItem
>
<Menubar.RadioItem value="google"
>{$_('menu.google')}</Menubar.RadioItem
>{i18n._('menu.google')}</Menubar.RadioItem
>
</Menubar.RadioGroup>
</Menubar.SubContent>
</Menubar.Sub>
<Menubar.Item on:click={() => (layerSettingsOpen = true)}>
<Layers size="16" class="mr-1" />
{$_('menu.layers')}
<Menubar.Item onclick={() => (layerSettingsOpen = true)}>
<Layers size="16" />
{i18n._('menu.layers')}
</Menubar.Item>
</Menubar.Content>
</Menubar.Menu>
@@ -513,11 +506,11 @@
href="./help"
target="_blank"
class="cursor-default h-fit rounded-sm px-3 py-0.5"
aria-label={$_('menu.help')}
aria-label={i18n._('menu.help')}
>
<BookOpenText size="18" class="md:hidden" />
<span class="hidden md:block">
{$_('menu.help')}
{i18n._('menu.help')}
</span>
</Button>
<Button
@@ -525,12 +518,12 @@
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')}
aria-label={i18n._('menu.donate')}
>
<HeartHandshake size="18" class="md:hidden" />
<span class="hidden md:flex flex-row items-center">
{$_('menu.donate')}
<Heart size="16" class="ml-1" fill="rgb(var(--support))" />
{i18n._('menu.donate')}
<Heart size="16" class="ml-1" fill="var(--support)" />
</span>
</Button>
</div>
@@ -543,15 +536,17 @@
<svelte:window
on:keydown={(e) => {
let targetInput =
e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.tagName === 'SELECT' ||
e.target.role === 'combobox' ||
e.target.role === 'radio' ||
e.target.role === 'menu' ||
e.target.role === 'menuitem' ||
e.target.role === 'menuitemradio' ||
e.target.role === 'menuitemcheckbox';
e &&
e.target &&
(e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.tagName === 'SELECT' ||
e.target.role === 'combobox' ||
e.target.role === 'radio' ||
e.target.role === 'menu' ||
e.target.role === 'menuitem' ||
e.target.role === 'menuitemradio' ||
e.target.role === 'menuitemcheckbox');
if (e.key === '+' && (e.metaKey || e.ctrlKey)) {
createFile();
@@ -560,16 +555,16 @@
triggerFileInput();
e.preventDefault();
} else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
dbUtils.duplicateSelection();
fileActions.duplicateSelection();
e.preventDefault();
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
copySelection();
selection.copySelection();
e.preventDefault();
}
} else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
cutSelection();
selection.cutSelection();
e.preventDefault();
}
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
@@ -579,32 +574,32 @@
}
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
if ($fileObservers.size > 0) {
$exportState = ExportState.ALL;
if (fileStateCollection.size > 0) {
exportState.current = ExportState.ALL;
}
} else if ($selection.size > 0) {
$exportState = ExportState.SELECTION;
exportState.current = ExportState.SELECTION;
}
e.preventDefault();
} else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
dbUtils.redo();
fileActionManager.redo();
} else {
dbUtils.undo();
fileActionManager.undo();
}
e.preventDefault();
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
if (e.shiftKey) {
dbUtils.deleteAllFiles();
fileActions.deleteAllFiles();
} else {
dbUtils.deleteSelection();
fileActions.deleteSelection();
}
e.preventDefault();
}
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
selectAll();
selection.selectAll();
e.preventDefault();
}
} else if (e.key === 'i' && (e.metaKey || e.ctrlKey)) {
@@ -614,7 +609,7 @@
.getSelected()
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
) {
$editMetadata = true;
editMetadata.current = true;
}
e.preventDefault();
} else if (e.key === 'p' && (e.metaKey || e.ctrlKey)) {
@@ -625,14 +620,14 @@
e.preventDefault();
} else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) {
if ($allHidden) {
dbUtils.setHiddenToSelection(false);
fileActions.setHiddenToSelection(false);
} else {
dbUtils.setHiddenToSelection(true);
fileActions.setHiddenToSelection(true);
}
e.preventDefault();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
if ($selection.size > 0) {
centerMapOnSelection();
boundsManager.centerMapOnSelection();
}
} else if (e.key === 'F1') {
switchBasemaps();
@@ -649,28 +644,20 @@
} else if (e.key === 'F5') {
$routing = !$routing;
e.preventDefault();
} else if (
e.key === 'ArrowRight' ||
e.key === 'ArrowDown' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowUp'
) {
if (!targetInput) {
updateSelectionFromKey(e.key === 'ArrowRight' || e.key === 'ArrowDown', e.shiftKey);
e.preventDefault();
}
}
}}
on:dragover={(e) => e.preventDefault()}
on:drop={(e) => {
e.preventDefault();
if (e.dataTransfer.files.length > 0) {
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
loadFiles(e.dataTransfer.files);
}
}}
/>
<style lang="postcss">
@reference "../../app.css";
div :global(button) {
@apply hover:bg-accent;
@apply px-3;

View File

@@ -1,25 +1,28 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Moon, Sun } from 'lucide-svelte';
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
import { _ } from 'svelte-i18n';
import { Moon, Sun } from '@lucide/svelte';
import { mode, setMode } from 'mode-watcher';
import { i18n } from '$lib/i18n.svelte';
export let size = '20';
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
let {
class: className = '',
}: {
class?: string;
} = $props();
</script>
<Button
variant="ghost"
class="h-8 px-1.5 {$$props.class ?? ''}"
on:click={() => {
setMode(selectedMode === 'light' ? 'dark' : 'light');
size="icon"
class={className}
onclick={() => {
setMode(mode.current === 'light' ? 'dark' : 'light');
}}
aria-label={$_('menu.mode')}
aria-label={i18n._('menu.mode')}
>
{#if selectedMode === 'light'}
<Sun {size} />
{#if mode.current === 'light'}
<Sun />
{:else}
<Moon {size} />
<Moon />
{/if}
</Button>

View File

@@ -3,30 +3,42 @@
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';
import { BookOpenText, House, Map } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils';
</script>
<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">
<a href={getURLForLanguage(i18n.lang, '/')} class="shrink-0 translate-y-0.5">
<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" />
{$_('homepage.home')}
<Button
variant="link"
class="text-base px-0 has-[>svg]:px-0"
href={getURLForLanguage(i18n.lang, '/')}
>
<House size="18" />
{i18n._('homepage.home')}
</Button>
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/app')}>
<Map size="18" class="mr-1.5" />
{$_('homepage.app')}
<Button
variant="link"
class="text-base px-0 has-[>svg]:px-0"
href={getURLForLanguage(i18n.lang, '/app')}
>
<Map size="18" />
{i18n._('homepage.app')}
</Button>
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/help')}>
<BookOpenText size="18" class="mr-1.5" />
{$_('menu.help')}
<Button
variant="link"
class="text-base px-0 has-[>svg]:px-0"
href={getURLForLanguage(i18n.lang, '/help')}
>
<BookOpenText size="18" />
{i18n._('menu.help')}
</Button>
<AlgoliaDocSearch class="ml-auto" />
<ModeSwitch class="hidden xs:block" />
<ModeSwitch class="hidden xs:inline-flex" />
</div>
</nav>

View File

@@ -1,9 +1,15 @@
<script lang="ts">
export let orientation: 'col' | 'row' = 'col';
export let after: number;
export let minAfter: number = 0;
export let maxAfter: number = Number.MAX_SAFE_INTEGER;
let {
orientation = 'col',
after = $bindable(),
minAfter = 0,
maxAfter = Number.MAX_SAFE_INTEGER,
}: {
orientation?: 'col' | 'row';
after: number;
minAfter?: number;
maxAfter?: number;
} = $props();
function handleMouseDown(event: PointerEvent) {
const startX = event.clientX;
@@ -33,10 +39,10 @@
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="{orientation === 'col'
? 'w-1 h-full cursor-col-resize border-l'
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
on:pointerdown={handleMouseDown}
/>
onpointerdown={handleMouseDown}
></div>

View File

@@ -1,15 +1,25 @@
<script lang="ts">
import { isMac, isSafari } from '$lib/utils';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { i18n } from '$lib/i18n.svelte';
import * as Kbd from '$lib/components/ui/kbd/index.js';
export let key: string | undefined = undefined;
export let shift: boolean = false;
export let ctrl: boolean = false;
export let click: boolean = false;
let {
key = undefined,
shift = false,
ctrl = false,
click = false,
class: className = '',
}: {
key?: string;
shift?: boolean;
ctrl?: boolean;
click?: boolean;
class?: string;
} = $props();
let mac = false;
let safari = false;
let mac = $state(false);
let safari = $state(false);
onMount(() => {
mac = isMac();
@@ -17,20 +27,17 @@
});
</script>
<div
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
{...$$props}
>
<Kbd.Root class="ml-auto {className}">
{#if shift}
<span></span>
{/if}
{#if ctrl}
<span>{mac && !safari ? '⌘' : $_('menu.ctrl') + '+'}</span>
{mac && !safari ? '⌘' : i18n._('menu.ctrl')}
{/if}
{#if key}
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
{key}
{/if}
{#if click}
<span>{$_('menu.click')}</span>
{i18n._('menu.click')}
{/if}
</div>
</Kbd.Root>

View File

@@ -1,18 +1,32 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import type { Snippet } from 'svelte';
export let label: string;
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
let {
label,
side = 'top',
children,
extra,
class: className = '',
}: {
label: string;
side?: 'top' | 'right' | 'bottom' | 'left';
children: Snippet;
extra?: Snippet;
class?: string;
} = $props();
</script>
<Tooltip.Root>
<Tooltip.Trigger {...$$restProps} aria-label={label}>
<slot />
</Tooltip.Trigger>
<Tooltip.Content {side}>
<div class="flex flex-row items-center">
<span>{label}</span>
<slot name="extra" />
</div>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger class={className} aria-label={label}>
{@render children()}
</Tooltip.Trigger>
<Tooltip.Content {side}>
<div class="flex flex-row items-center gap-2">
<span>{label}</span>
{@render extra?.()}
</div>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { settings } from '$lib/db';
import {
celsiusToFahrenheit,
getConvertedDistance,
@@ -10,18 +9,27 @@
getVelocityUnits,
secondsToHHMMSS,
} from '$lib/units';
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings';
import { _ } from 'svelte-i18n';
export let value: number;
export let type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
export let showUnits: boolean = true;
export let decimals: number | undefined = undefined;
let {
value,
type,
showUnits = true,
decimals = undefined,
class: className = '',
}: {
value: number;
type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
showUnits?: boolean;
decimals?: number;
class?: string;
} = $props();
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
</script>
<span class={$$props.class}>
<span class={className}>
{#if type === 'distance'}
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getDistanceUnits($distanceUnits) : ''}
@@ -38,9 +46,9 @@
{/if}
{:else if type === 'temperature'}
{#if $temperatureUnits === 'celsius'}
{value} {showUnits ? $_('units.celsius') : ''}
{value} {showUnits ? i18n._('units.celsius') : ''}
{:else}
{celsiusToFahrenheit(value)} {showUnits ? $_('units.fahrenheit') : ''}
{celsiusToFahrenheit(value)} {showUnits ? i18n._('units.fahrenheit') : ''}
{/if}
{:else if type === 'time'}
{secondsToHHMMSS(value)}

View File

@@ -1,15 +1,23 @@
<script lang="ts">
import { setContext } from 'svelte';
import { writable } from 'svelte/store';
import { setContext, type Snippet } from 'svelte';
import { CollapsibleTreeState } from './utils.svelte';
export let defaultState: 'open' | 'closed' = 'open';
export let side: 'left' | 'right' = 'right';
export let nohover: boolean = false;
export let slotInsideTrigger: boolean = true;
const {
defaultState = 'open',
side = 'right',
nohover = false,
slotInsideTrigger = true,
children,
}: {
defaultState?: 'open' | 'closed';
side?: 'left' | 'right';
nohover?: boolean;
slotInsideTrigger?: boolean;
children: Snippet;
} = $props();
let open = writable<Record<string, boolean>>({});
let open = $state(new CollapsibleTreeState(defaultState));
setContext('collapsible-tree-default-state', defaultState);
setContext('collapsible-tree-state', open);
setContext('collapsible-tree-side', side);
setContext('collapsible-tree-nohover', nohover);
@@ -17,4 +25,4 @@
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
</script>
<slot />
{@render children()}

View File

@@ -1,60 +1,56 @@
<script lang="ts">
import * as Collapsible from '$lib/components/ui/collapsible';
import { Button } from '$lib/components/ui/button';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte';
import { getContext, onMount, setContext } from 'svelte';
import { get, type Writable } from 'svelte/store';
import { ChevronDown, ChevronLeft, ChevronRight } from '@lucide/svelte';
import { getContext, setContext, type Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import type { CollapsibleTreeState } from './utils.svelte';
export let id: string | number;
const props: {
id: string | number;
class?: ClassValue;
trigger: Snippet;
content: Snippet;
} = $props();
let defaultState = getContext<'open' | 'closed'>('collapsible-tree-default-state');
let open = getContext<Writable<Record<string, boolean>>>('collapsible-tree-state');
let state = getContext<CollapsibleTreeState>('collapsible-tree-state');
let side = getContext<'left' | 'right'>('collapsible-tree-side');
let nohover = getContext<boolean>('collapsible-tree-nohover');
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
let parentId = getContext<string>('collapsible-tree-parent-id');
let fullId = `${parentId}.${id}`;
let fullId = `${parentId}.${props.id}`;
setContext('collapsible-tree-parent-id', fullId);
onMount(() => {
if (!get(open).hasOwnProperty(fullId)) {
open.update((value) => {
value[fullId] = defaultState === 'open';
return value;
});
}
});
let open = state.get(fullId);
export function openNode() {
open.update((value) => {
value[fullId] = true;
return value;
});
open.current = true;
}
</script>
<Collapsible.Root bind:open={$open[fullId]} class={$$props.class ?? ''}>
<Collapsible.Root bind:open={open.current} class={props.class}>
{#if slotInsideTrigger}
<Collapsible.Trigger class="w-full">
<Button
variant="ghost"
class="w-full flex flex-row {side === 'right'
size="icon"
class="w-full flex flex-row gap-1 {side === 'right'
? 'justify-between'
: 'justify-start'} py-0 px-1 h-fit {nohover
: 'justify-start pl-1'} h-fit {nohover
? 'hover:bg-background'
: ''} pointer-events-none"
>
{#if side === 'left'}
{#if $open[fullId]}
{#if open.current}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronRight size="16" class="shrink-0" />
{/if}
{/if}
<slot name="trigger" />
{@render props.trigger()}
{#if side === 'right'}
{#if $open[fullId]}
{#if open.current}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronLeft size="16" class="shrink-0" />
@@ -65,23 +61,24 @@
{:else}
<Button
variant="ghost"
class="w-full flex flex-row {side === 'right'
size="icon"
class="w-full flex flex-row gap-1 {side === 'right'
? 'justify-between'
: 'justify-start'} py-0 px-1 h-fit {nohover ? 'hover:bg-background' : ''}"
: 'justify-start pl-1'} h-fit {nohover ? 'hover:bg-background' : ''}"
>
{#if side === 'left'}
<Collapsible.Trigger>
{#if $open[fullId]}
{#if open.current}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronRight size="16" class="shrink-0" />
{/if}
</Collapsible.Trigger>
{/if}
<slot name="trigger" />
{@render props.trigger()}
{#if side === 'right'}
<Collapsible.Trigger>
{#if $open[fullId]}
{#if open.current}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronLeft size="16" class="shrink-0" />
@@ -90,8 +87,7 @@
{/if}
</Button>
{/if}
<Collapsible.Content class="ml-2">
<slot name="content" />
<Collapsible.Content>
{@render props.content()}
</Collapsible.Content>
</Collapsible.Root>

View File

@@ -0,0 +1,31 @@
export class CollapsibleNodeState {
private _open: boolean;
constructor(defaultState: 'open' | 'closed') {
this._open = $state(defaultState === 'open');
}
get current(): boolean {
return this._open;
}
set current(value: boolean) {
this._open = value;
}
}
export class CollapsibleTreeState {
private _open: Record<string, CollapsibleNodeState> = {};
private _defaultState: 'open' | 'closed';
constructor(defaultState: 'open' | 'closed') {
this._defaultState = defaultState;
}
get(id: string): CollapsibleNodeState {
if (this._open[id] === undefined) {
this._open[id] = new CollapsibleNodeState(this._defaultState);
}
return this._open[id];
}
}

View File

@@ -1,27 +0,0 @@
<script lang="ts">
import CustomControl from './CustomControl';
import { map } from '$lib/stores';
export let position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' = 'top-right';
let container: HTMLDivElement;
let control: CustomControl | undefined = undefined;
$: if ($map && container) {
if (position.includes('right')) container.classList.add('float-right');
else container.classList.add('float-left');
container.classList.remove('hidden');
if (control === undefined) {
control = new CustomControl(container);
}
$map.addControl(control, position);
}
</script>
<div
bind:this={container}
class="{$$props.class ||
''} clear-both translate-0 m-[10px] mb-0 last:mb-[10px] pointer-events-auto bg-background rounded shadow-md hidden"
>
<slot />
</div>

View File

@@ -1,14 +1,16 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { Component } from 'svelte';
export let module;
let { module: Module }: { module: Component } = $props();
</script>
<div class="markdown flex flex-col gap-3">
<svelte:component this={module} />
<Module />
</div>
<style lang="postcss">
@reference "../../../app.css";
:global(.markdown) {
@apply text-muted-foreground;
}

View File

@@ -1,6 +1,11 @@
<script lang="ts">
export let src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
export let alt: string;
let {
src,
alt,
}: {
src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
alt: string;
} = $props();
</script>
<div class="flex flex-col items-center py-6 w-full">

View File

@@ -1,5 +1,7 @@
<script lang="ts">
export let type: 'note' | 'warning' = 'note';
import type { Snippet } from 'svelte';
let { type = 'note', children }: { type?: 'note' | 'warning'; children: Snippet } = $props();
</script>
<div
@@ -7,10 +9,12 @@
? 'border-link'
: 'border-destructive'} p-2 text-sm rounded-md"
>
<slot />
{@render children()}
</div>
<style lang="postcss">
@reference "../../../app.css";
div :global(a) {
@apply text-link;
@apply hover:underline;

View File

@@ -2,7 +2,6 @@ import {
File,
FilePen,
View,
type Icon,
Settings,
Pencil,
MapPin,
@@ -10,11 +9,12 @@ import {
CalendarClock,
Group,
Ungroup,
Filter,
Funnel,
SquareDashedMousePointer,
MountainSnow,
} from 'lucide-svelte';
import type { ComponentType } from 'svelte';
type IconProps,
} from '@lucide/svelte';
import type { Component } from 'svelte';
export const guides: Record<string, string[]> = {
'getting-started': [],
@@ -37,7 +37,7 @@ export const guides: Record<string, string[]> = {
faq: [],
};
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
export const guideIcons: Record<string, string | Component<IconProps>> = {
'getting-started': '🚀',
menu: '📂 ⚙️',
file: File,
@@ -53,7 +53,7 @@ export const guideIcons: Record<string, string | ComponentType<Icon>> = {
merge: Group,
extract: Ungroup,
elevation: MountainSnow,
minify: Filter,
minify: Funnel,
clean: SquareDashedMousePointer,
'map-controls': '🗺',
gpx: '💾',

View File

@@ -0,0 +1,203 @@
<script lang="ts">
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
import * as Popover from '$lib/components/ui/popover/index.js';
import * as ToggleGroup from '$lib/components/ui/toggle-group/index.js';
import Separator from '$lib/components/ui/separator/separator.svelte';
import { onDestroy, onMount } from 'svelte';
import {
BrickWall,
TriangleRight,
HeartPulse,
Orbit,
SquareActivity,
Thermometer,
Zap,
Circle,
Check,
ChartNoAxesColumn,
Construction,
} from '@lucide/svelte';
import type { Readable, Writable } from 'svelte/store';
import type { GPXStatistics } from 'gpx';
import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n.svelte';
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
const { velocityUnits } = settings;
let {
gpxStatistics,
slicedGPXStatistics,
additionalDatasets,
elevationFill,
showControls = true,
}: {
gpxStatistics: Readable<GPXStatistics>;
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
additionalDatasets: Writable<string[]>;
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
showControls?: boolean;
} = $props();
let canvas: HTMLCanvasElement;
let overlay: HTMLCanvasElement;
let elevationProfile: ElevationProfile | null = null;
onMount(() => {
elevationProfile = new ElevationProfile(
gpxStatistics,
slicedGPXStatistics,
additionalDatasets,
elevationFill,
canvas,
overlay
);
});
onDestroy(() => {
if (elevationProfile) {
elevationProfile.destroy();
}
});
</script>
<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="absolute bottom-10 right-1.5">
<Popover.Root>
<Popover.Trigger>
<ButtonWithTooltip
label={i18n._('chart.settings')}
variant="outline"
side="left"
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"
side="top"
align="end"
sideOffset={-32}
>
<ToggleGroup.Root
class="flex flex-col items-start gap-0 p-1 w-full border-none"
type="single"
bind:value={$elevationFill}
>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="slope"
>
<div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'slope'}
<Circle class="size-1.5 fill-current text-current" />
{/if}
</div>
<TriangleRight size="15" />
{i18n._('quantities.slope')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="surface"
variant="outline"
>
<div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'surface'}
<Circle class="size-1.5 fill-current text-current" />
{/if}
</div>
<BrickWall size="15" />
{i18n._('quantities.surface')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="highway"
variant="outline"
>
<div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'highway'}
<Circle class="size-1.5 fill-current text-current" />
{/if}
</div>
<Construction size="15" />
{i18n._('quantities.highway')}
</ToggleGroup.Item>
</ToggleGroup.Root>
<Separator />
<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 gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="speed"
>
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('speed')}
<Check size="14" />
{/if}
</div>
<Zap size="15" />
{$velocityUnits === 'speed'
? i18n._('quantities.speed')
: i18n._('quantities.pace')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="hr"
>
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('hr')}
<Check size="14" />
{/if}
</div>
<HeartPulse size="15" />
{i18n._('quantities.heartrate')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="cad"
>
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('cad')}
<Check size="14" />
{/if}
</div>
<Orbit size="15" />
{i18n._('quantities.cadence')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="atemp"
>
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('atemp')}
<Check size="14" />
{/if}
</div>
<Thermometer size="15" />
{i18n._('quantities.temperature')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="power"
>
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('power')}
<Check size="14" />
{/if}
</div>
<SquareActivity size="15" />
{i18n._('quantities.power')}
</ToggleGroup.Item>
</ToggleGroup.Root>
</Popover.Content>
</Popover.Root>
</div>
{/if}
</div>

View File

@@ -0,0 +1,603 @@
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings';
import {
getCadenceWithUnits,
getConvertedDistance,
getConvertedElevation,
getConvertedTemperature,
getConvertedVelocity,
getDistanceUnits,
getDistanceWithUnits,
getElevationWithUnits,
getHeartRateWithUnits,
getPowerWithUnits,
getTemperatureWithUnits,
getVelocityWithUnits,
} from '$lib/units';
import Chart from 'chart.js/auto';
import mapboxgl from 'mapbox-gl';
import { get, type Readable, type Writable } from 'svelte/store';
import { map } from '$lib/components/map/map';
import type { GPXStatistics } from 'gpx';
import { mode } from 'mode-watcher';
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
Chart.defaults.font.family =
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
export class ElevationProfile {
private _chart: Chart | null = null;
private _canvas: HTMLCanvasElement;
private _overlay: HTMLCanvasElement;
private _marker: mapboxgl.Marker | null = null;
private _dragging = false;
private _panning = false;
private _gpxStatistics: Readable<GPXStatistics>;
private _slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
private _additionalDatasets: Readable<string[]>;
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
constructor(
gpxStatistics: Readable<GPXStatistics>,
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>,
additionalDatasets: Readable<string[]>,
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
canvas: HTMLCanvasElement,
overlay: HTMLCanvasElement
) {
this._gpxStatistics = gpxStatistics;
this._slicedGPXStatistics = slicedGPXStatistics;
this._additionalDatasets = additionalDatasets;
this._elevationFill = elevationFill;
this._canvas = canvas;
this._overlay = overlay;
let element = document.createElement('div');
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
this._marker = new mapboxgl.Marker({
element,
});
import('chartjs-plugin-zoom').then((module) => {
Chart.register(module.default);
this.initialize();
this._gpxStatistics.subscribe(() => {
this.updateData();
});
this._slicedGPXStatistics.subscribe(() => {
this.updateOverlay();
});
distanceUnits.subscribe(() => {
this.updateData();
});
velocityUnits.subscribe(() => {
this.updateData();
});
temperatureUnits.subscribe(() => {
this.updateData();
});
this._additionalDatasets.subscribe(() => {
this.updateDataVisibility();
});
this._elevationFill.subscribe(() => {
this.updateFill();
});
});
}
initialize() {
let options = {
animation: false,
parsing: false,
maintainAspectRatio: false,
scales: {
x: {
type: 'linear',
ticks: {
callback: function (value: number) {
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
},
align: 'inner',
maxRotation: 0,
},
},
y: {
type: 'linear',
ticks: {
callback: function (value: number) {
return getElevationWithUnits(value, false);
},
},
},
},
datasets: {
line: {
pointRadius: 0,
tension: 0.4,
borderWidth: 2,
cubicInterpolationMode: 'monotone',
},
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false,
},
plugins: {
legend: {
display: false,
},
decimation: {
enabled: true,
},
tooltip: {
enabled: () => !this._dragging && !this._panning,
callbacks: {
title: () => {
return '';
},
label: (context: Chart.TooltipContext) => {
let point = context.raw;
if (context.datasetIndex === 0) {
const map_ = get(map);
if (map_ && this._marker) {
if (this._dragging) {
this._marker.remove();
} else {
this._marker.setLngLat(point.coordinates);
this._marker.addTo(map_);
}
}
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 1) {
return `${get(velocityUnits) === 'speed' ? i18n._('quantities.speed') : i18n._('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 2) {
return `${i18n._('quantities.heartrate')}: ${getHeartRateWithUnits(point.y)}`;
} else if (context.datasetIndex === 3) {
return `${i18n._('quantities.cadence')}: ${getCadenceWithUnits(point.y)}`;
} else if (context.datasetIndex === 4) {
return `${i18n._('quantities.temperature')}: ${getTemperatureWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 5) {
return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`;
}
},
afterBody: (contexts: Chart.TooltipContext[]) => {
let context = contexts.filter((context) => context.datasetIndex === 0);
if (context.length === 0) return;
let point = context[0].raw;
let slope = {
at: point.slope.at.toFixed(1),
segment: point.slope.segment.toFixed(1),
length: getDistanceWithUnits(point.slope.length),
};
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 = [
` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
` ${i18n._('quantities.slope')}: ${slope.at} %${get(this._elevationFill) === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`,
];
if (get(this._elevationFill) === 'surface') {
labels.push(
` ${i18n._('quantities.surface')}: ${i18n._(`toolbar.routing.surface.${surface}`)}`
);
}
if (get(this._elevationFill) === 'highway') {
labels.push(
` ${i18n._('quantities.highway')}: ${i18n._(`toolbar.routing.highway.${highway}`)}${
sacScale
? ` (${i18n._(`toolbar.routing.sac_scale.${sacScale}`)})`
: ''
}`
);
if (mtbScale) {
labels.push(
` ${i18n._('toolbar.routing.mtb_scale')}: ${mtbScale}`
);
}
}
if (point.time) {
labels.push(
` ${i18n._('quantities.time')}: ${i18n.df.format(point.time)}`
);
}
return labels;
},
},
},
zoom: {
pan: {
enabled: true,
mode: 'x',
modifierKey: 'shift',
onPanStart: () => {
this._panning = true;
this._slicedGPXStatistics.set(undefined);
},
onPanComplete: () => {
this._panning = false;
},
},
zoom: {
wheel: {
enabled: true,
},
mode: 'x',
onZoomStart: ({ chart, event }: { chart: Chart; event: any }) => {
if (
event.deltaY < 0 &&
Math.abs(
this._chart.getInitialScaleBounds().x.max /
this._chart.options.plugins.zoom.limits.x.minRange -
this._chart.getZoomLevel()
) < 0.01
) {
// Disable wheel pan if zoomed in to the max, and zooming in
return false;
}
this._slicedGPXStatistics.set(undefined);
},
},
limits: {
x: {
min: 'original',
max: 'original',
minRange: 1,
},
},
},
},
stacked: false,
onResize: () => {
this.updateOverlay();
},
};
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
datasets.forEach((id) => {
options.scales[`y${id}`] = {
type: 'linear',
position: 'right',
grid: {
display: false,
},
reverse: () => id === 'speed' && get(velocityUnits) === 'pace',
display: false,
};
});
this._chart = new Chart(this._canvas, {
type: 'line',
data: {
datasets: [],
},
options,
plugins: [
{
id: 'toggleMarker',
events: ['mouseout'],
afterEvent: (chart: Chart, args: { event: Chart.ChartEvent }) => {
if (args.event.type === 'mouseout') {
const map_ = get(map);
if (map_ && this._marker) {
this._marker.remove();
}
}
},
},
],
});
let startIndex = 0;
let endIndex = 0;
const getIndex = (evt) => {
if (!this._chart) {
return undefined;
}
const points = this._chart.getElementsAtEventForMode(
evt,
'x',
{
intersect: false,
},
true
);
if (points.length === 0) {
const rect = this._canvas.getBoundingClientRect();
if (evt.x - rect.left <= this._chart.chartArea.left) {
return 0;
} else if (evt.x - rect.left >= this._chart.chartArea.right) {
return get(this._gpxStatistics).local.points.length - 1;
} else {
return undefined;
}
}
let point = points.find((point) => point.element.raw);
if (point) {
return point.element.raw.index;
} else {
return points[0].index;
}
};
let dragStarted = false;
const onMouseDown = (evt) => {
if (evt.shiftKey) {
// Panning interaction
return;
}
dragStarted = true;
this._canvas.style.cursor = 'col-resize';
startIndex = getIndex(evt);
};
const onMouseMove = (evt) => {
if (dragStarted) {
this._dragging = true;
endIndex = getIndex(evt);
if (endIndex !== undefined) {
if (startIndex === undefined) {
startIndex = endIndex;
} else if (startIndex !== endIndex) {
this._slicedGPXStatistics.set([
get(this._gpxStatistics).slice(
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex)
),
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex),
]);
}
}
}
};
const onMouseUp = (evt) => {
dragStarted = false;
this._dragging = false;
this._canvas.style.cursor = '';
endIndex = getIndex(evt);
if (startIndex === endIndex) {
this._slicedGPXStatistics.set(undefined);
}
};
this._canvas.addEventListener('pointerdown', onMouseDown);
this._canvas.addEventListener('pointermove', onMouseMove);
this._canvas.addEventListener('pointerup', onMouseUp);
}
updateData() {
if (!this._chart) {
return;
}
const data = get(this._gpxStatistics);
this._chart.data.datasets[0] = {
label: i18n._('quantities.elevation'),
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.ele ? getConvertedElevation(point.ele) : 0,
time: point.time,
slope: {
at: data.local.slope.at[index],
segment: data.local.slope.segment[index],
length: data.local.slope.length[index],
},
extensions: point.getExtensions(),
coordinates: point.getCoordinates(),
index: index,
};
}),
normalized: true,
fill: 'start',
order: 1,
segment: {},
};
this._chart.data.datasets[1] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: getConvertedVelocity(data.local.speed[index]),
index: index,
};
}),
normalized: true,
yAxisID: 'yspeed',
};
this._chart.data.datasets[2] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getHeartRate(),
index: index,
};
}),
normalized: true,
yAxisID: 'yhr',
};
this._chart.data.datasets[3] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getCadence(),
index: index,
};
}),
normalized: true,
yAxisID: 'ycad',
};
this._chart.data.datasets[4] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: getConvertedTemperature(point.getTemperature()),
index: index,
};
}),
normalized: true,
yAxisID: 'yatemp',
};
this._chart.data.datasets[5] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getPower(),
index: index,
};
}),
normalized: true,
yAxisID: 'ypower',
};
this._chart.options.scales.x['min'] = 0;
this._chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
this.setVisibility();
this.setFill();
this._chart.update();
}
updateDataVisibility() {
if (!this._chart) {
return;
}
this.setVisibility();
this._chart.update();
}
setVisibility() {
if (!this._chart) {
return;
}
const additionalDatasets = get(this._additionalDatasets);
let includeSpeed = additionalDatasets.includes('speed');
let includeHeartRate = additionalDatasets.includes('hr');
let includeCadence = additionalDatasets.includes('cad');
let includeTemperature = additionalDatasets.includes('atemp');
let includePower = additionalDatasets.includes('power');
if (this._chart.data.datasets.length == 6) {
this._chart.data.datasets[1].hidden = !includeSpeed;
this._chart.data.datasets[2].hidden = !includeHeartRate;
this._chart.data.datasets[3].hidden = !includeCadence;
this._chart.data.datasets[4].hidden = !includeTemperature;
this._chart.data.datasets[5].hidden = !includePower;
}
}
updateFill() {
if (!this._chart) {
return;
}
this.setFill();
this._chart.update();
}
setFill() {
if (!this._chart) {
return;
}
const elevationFill = get(this._elevationFill);
if (elevationFill === 'slope') {
this._chart.data.datasets[0]['segment'] = {
backgroundColor: this.slopeFillCallback,
};
} else if (elevationFill === 'surface') {
this._chart.data.datasets[0]['segment'] = {
backgroundColor: this.surfaceFillCallback,
};
} else if (elevationFill === 'highway') {
this._chart.data.datasets[0]['segment'] = {
backgroundColor: this.highwayFillCallback,
};
} else {
this._chart.data.datasets[0]['segment'] = {};
}
}
updateOverlay() {
if (!this._chart) {
return;
}
this._overlay.width = this._canvas.width / window.devicePixelRatio;
this._overlay.height = this._canvas.height / window.devicePixelRatio;
this._overlay.style.width = `${this._overlay.width}px`;
this._overlay.style.height = `${this._overlay.height}px`;
const slicedGPXStatistics = get(this._slicedGPXStatistics);
if (slicedGPXStatistics) {
let startIndex = slicedGPXStatistics[1];
let endIndex = slicedGPXStatistics[2];
// Draw selection rectangle
let selectionContext = this._overlay.getContext('2d');
if (selectionContext) {
selectionContext.fillStyle = mode.current === 'dark' ? 'white' : 'black';
selectionContext.globalAlpha = mode.current === 'dark' ? 0.2 : 0.1;
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
const gpxStatistics = get(this._gpxStatistics);
let startPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(gpxStatistics.local.distance.total[startIndex])
);
let endPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(gpxStatistics.local.distance.total[endIndex])
);
selectionContext.fillRect(
startPixel,
this._chart.chartArea.top,
endPixel - startPixel,
this._chart.chartArea.height
);
}
} else if (this._overlay) {
let selectionContext = this._overlay.getContext('2d');
if (selectionContext) {
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
}
}
}
slopeFillCallback(context) {
return getSlopeColor(context.p0.raw.slope.segment);
}
surfaceFillCallback(context) {
return getSurfaceColor(context.p0.raw.extensions.surface);
}
highwayFillCallback(context) {
return getHighwayColor(
context.p0.raw.extensions.highway,
context.p0.raw.extensions.sac_scale,
context.p0.raw.extensions.mtb_scale
);
}
destroy() {
if (this._chart) {
this._chart.destroy();
this._chart = null;
}
if (this._marker) {
this._marker.remove();
}
}
}

View File

@@ -1,34 +1,34 @@
<script lang="ts">
import GPXLayers from '$lib/components/gpx-layer/GPXLayers.svelte';
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
import GPXLayers from '$lib/components/map/gpx-layer/GPXLayers.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import FileList from '$lib/components/file-list/FileList.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import Map from '$lib/components/Map.svelte';
import LayerControl from '$lib/components/layer-control/LayerControl.svelte';
import Map from '$lib/components/map/Map.svelte';
import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
import {
gpxStatistics,
slicedGPXStatistics,
embedding,
loadFile,
map,
updateGPXData,
} from '$lib/stores';
import { onDestroy, onMount } from 'svelte';
import { fileObservers, settings, GPXStatisticsTree } from '$lib/db';
import { readable } from 'svelte/store';
import { writable } from 'svelte/store';
import type { GPXFile } from 'gpx';
import { selection } from '$lib/components/file-list/Selection';
import { ListFileItem } from '$lib/components/file-list/FileList';
import {
allowedEmbeddingBasemaps,
getFilesFromEmbeddingOptions,
type EmbeddingOptions,
} from './Embedding';
import { mode, setMode } from 'mode-watcher';
import { browser } from '$app/environment';
} from './embedding';
import { setMode } from 'mode-watcher';
import { settings } from '$lib/logic/settings';
import { fileStateCollection } from '$lib/logic/file-state';
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
import { loadFile } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection';
import { untrack } from 'svelte';
$embedding = true;
let {
useHash = true,
options = $bindable(),
hash = $bindable(),
}: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props();
let additionalDatasets = writable<string[]>([]);
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
const {
currentBasemap,
@@ -40,203 +40,72 @@
directionMarkers,
} = settings;
export let useHash = true;
export let options: EmbeddingOptions;
export let hash: string;
let prevSettings = {
distanceMarkers: false,
directionMarkers: false,
distanceUnits: 'metric',
velocityUnits: 'speed',
temperatureUnits: 'celsius',
theme: 'system',
};
settings.initialize();
function applyOptions() {
fileObservers.update(($fileObservers) => {
$fileObservers.clear();
return $fileObservers;
});
let downloads: Promise<GPXFile | null>[] = [];
getFilesFromEmbeddingOptions(options).forEach((url) => {
downloads.push(
fetch(url)
let downloads: Promise<GPXFile | null>[] = getFilesFromEmbeddingOptions(options).map(
(url) => {
return fetch(url)
.then((response) => response.blob())
.then((blob) => new File([blob], url.split('/').pop() ?? url))
.then(loadFile)
);
});
Promise.all(downloads).then((files) => {
let ids: string[] = [];
let bounds = {
southWest: {
lat: 90,
lon: 180,
},
northEast: {
lat: -90,
lon: -180,
},
};
fileObservers.update(($fileObservers) => {
files.forEach((file, index) => {
if (file === null) {
return;
}
let id = `gpx-${index}-embed`;
file._data.id = id;
let statistics = new GPXStatisticsTree(file);
$fileObservers.set(
id,
readable({
file,
statistics,
})
);
ids.push(id);
let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global
.bounds;
bounds.southWest.lat = Math.min(bounds.southWest.lat, fileBounds.southWest.lat);
bounds.southWest.lon = Math.min(bounds.southWest.lon, fileBounds.southWest.lon);
bounds.northEast.lat = Math.max(bounds.northEast.lat, fileBounds.northEast.lat);
bounds.northEast.lon = Math.max(bounds.northEast.lon, fileBounds.northEast.lon);
});
return $fileObservers;
});
$fileOrder = [...$fileOrder.filter((id) => !id.includes('embed')), ...ids];
selection.update(($selection) => {
$selection.clear();
ids.forEach((id) => {
$selection.toggle(new ListFileItem(id));
});
return $selection;
});
if (hash.length === 0) {
map.subscribe(($map) => {
if ($map) {
$map.fitBounds(
[
bounds.southWest.lon,
bounds.southWest.lat,
bounds.northEast.lon,
bounds.northEast.lat,
],
{
padding: 80,
linear: true,
easing: () => 1,
}
);
}
});
.then(loadFile);
}
);
Promise.all(downloads).then((answers) => {
const files = answers.filter((file) => file !== null) as GPXFile[];
let ids: string[] = [];
files.forEach((file, index) => {
let id = `gpx-${index}-embed`;
file._data.id = id;
ids.push(id);
});
fileStateCollection.setEmbeddedFiles(files);
$fileOrder = ids;
selection.selectAll();
});
if (
options.basemap !== $currentBasemap &&
allowedEmbeddingBasemaps.includes(options.basemap)
) {
if (allowedEmbeddingBasemaps.includes(options.basemap)) {
$currentBasemap = options.basemap;
}
if (options.distanceMarkers !== $distanceMarkers) {
$distanceMarkers = options.distanceMarkers;
}
if (options.directionMarkers !== $directionMarkers) {
$directionMarkers = options.directionMarkers;
}
if (options.distanceUnits !== $distanceUnits) {
$distanceUnits = options.distanceUnits;
}
if (options.velocityUnits !== $velocityUnits) {
$velocityUnits = options.velocityUnits;
}
if (options.temperatureUnits !== $temperatureUnits) {
$temperatureUnits = options.temperatureUnits;
}
if (options.theme !== $mode) {
$distanceMarkers = options.distanceMarkers;
$directionMarkers = options.directionMarkers;
$distanceUnits = options.distanceUnits;
$velocityUnits = options.velocityUnits;
$temperatureUnits = options.temperatureUnits;
if (options.theme != 'system') {
setMode(options.theme);
}
additionalDatasets.set(
[
options.elevation.speed ? 'speed' : null,
options.elevation.hr ? 'hr' : null,
options.elevation.cad ? 'cad' : null,
options.elevation.temp ? 'temp' : null,
options.elevation.power ? 'power' : null,
].filter((dataset) => dataset !== null)
);
elevationFill.set(options.elevation.fill == 'none' ? undefined : options.elevation.fill);
}
onMount(() => {
prevSettings.distanceMarkers = $distanceMarkers;
prevSettings.directionMarkers = $directionMarkers;
prevSettings.distanceUnits = $distanceUnits;
prevSettings.velocityUnits = $velocityUnits;
prevSettings.temperatureUnits = $temperatureUnits;
prevSettings.theme = $mode ?? 'system';
});
$: if (browser && options) {
applyOptions();
}
$: if ($fileOrder) {
updateGPXData();
}
onDestroy(() => {
if ($distanceMarkers !== prevSettings.distanceMarkers) {
$distanceMarkers = prevSettings.distanceMarkers;
}
if ($directionMarkers !== prevSettings.directionMarkers) {
$directionMarkers = prevSettings.directionMarkers;
}
if ($distanceUnits !== prevSettings.distanceUnits) {
$distanceUnits = prevSettings.distanceUnits;
}
if ($velocityUnits !== prevSettings.velocityUnits) {
$velocityUnits = prevSettings.velocityUnits;
}
if ($temperatureUnits !== prevSettings.temperatureUnits) {
$temperatureUnits = prevSettings.temperatureUnits;
}
if ($mode !== prevSettings.theme) {
setMode(prevSettings.theme);
}
$selection.clear();
$fileObservers.clear();
$fileOrder = $fileOrder.filter((id) => !id.includes('embed'));
$effect(() => {
options;
untrack(applyOptions);
});
</script>
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
<div class="grow relative">
<Map
class="h-full {$fileObservers.size > 1 ? 'horizontal' : ''}"
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
accessToken={options.token}
geocoder={false}
geolocate={false}
geolocate={true}
hash={useHash}
/>
<OpenIn bind:files={options.files} bind:ids={options.ids} />
<OpenIn files={options.files} ids={options.ids} />
<LayerControl />
<GPXLayers />
{#if $fileObservers.size > 1}
{#if $fileStateCollection.size > 1}
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
<FileList orientation="horizontal" />
</div>
@@ -256,14 +125,8 @@
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
additionalDatasets={[
options.elevation.speed ? 'speed' : null,
options.elevation.hr ? 'hr' : null,
options.elevation.cad ? 'cad' : null,
options.elevation.temp ? 'temp' : null,
options.elevation.power ? 'power' : null,
].filter((dataset) => dataset !== null)}
elevationFill={options.elevation.fill}
{additionalDatasets}
{elevationFill}
showControls={options.elevation.controls}
/>
{/if}

View File

@@ -14,67 +14,65 @@
Coins,
Milestone,
Video,
} from 'lucide-svelte';
import { _ } from 'svelte-i18n';
} from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import {
allowedEmbeddingBasemaps,
defaultEmbeddingOptions,
getCleanedEmbeddingOptions,
getDefaultEmbeddingOptions,
} from './Embedding';
getMergedEmbeddingOptions,
} from './embedding';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import Embedding from './Embedding.svelte';
import { map } from '$lib/stores';
import { tick } from 'svelte';
import { onDestroy } from 'svelte';
import { base } from '$app/paths';
import { map } from '$lib/components/map/map';
import { mode } from 'mode-watcher';
let options = getDefaultEmbeddingOptions();
options.token = 'YOUR_MAPBOX_TOKEN';
options.files = [
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx',
];
let options = $state(
getMergedEmbeddingOptions(
{
token: 'YOUR_MAPBOX_TOKEN',
theme: mode.current,
},
defaultEmbeddingOptions
)
);
let files = $state(
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
);
let driveIds = $state('');
let files = options.files[0];
$: {
let urls = files.split(',');
urls = urls.filter((url) => url.length > 0);
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
options.files = urls;
let iframeOptions = $derived(
getMergedEmbeddingOptions(
{
token:
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
? PUBLIC_MAPBOX_TOKEN
: options.token,
files: files.split(',').filter((url) => url.length > 0),
ids: driveIds.split(',').filter((id) => id.length > 0),
elevation: {
fill: options.elevation.fill === 'none' ? undefined : options.elevation.fill,
},
},
options
)
);
let manualCamera = $state(false);
let zoom = $state('0');
let lat = $state('0');
let lon = $state('0');
let bearing = $state('0');
let pitch = $state('0');
let hash = $derived(manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '');
$effect(() => {
if (options.elevation.show || options.elevation.height) {
map.resize();
}
}
let driveIds = '';
$: {
let ids = driveIds.split(',');
ids = ids.filter((id) => id.length > 0);
if (JSON.stringify(ids) !== JSON.stringify(options.ids)) {
options.ids = ids;
}
}
let manualCamera = false;
let zoom = '0';
let lat = '0';
let lon = '0';
let bearing = '0';
let pitch = '0';
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
$: iframeOptions =
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
: options;
async function resizeMap() {
if ($map) {
await tick();
$map.resize();
}
}
$: if (options.elevation.height || options.elevation.show) {
resizeMap();
}
});
function updateCamera() {
if ($map) {
@@ -87,49 +85,50 @@
}
}
$: if ($map) {
$map.on('moveend', updateCamera);
}
map.onLoad((map_) => {
map_.on('moveend', updateCamera);
});
onDestroy(() => {
if ($map) {
$map.off('moveend', updateCamera);
}
});
</script>
<Card.Root id="embedding-playground">
<Card.Header>
<Card.Title>{$_('embedding.title')}</Card.Title>
<Card.Title>{i18n._('embedding.title')}</Card.Title>
</Card.Header>
<Card.Content>
<fieldset class="flex flex-col gap-3">
<Label for="token">{$_('embedding.mapbox_token')}</Label>
<Label for="token">{i18n._('embedding.mapbox_token')}</Label>
<Input id="token" type="text" class="h-8" bind:value={options.token} />
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
<Label for="file_urls">{i18n._('embedding.file_urls')}</Label>
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
<Label for="drive_ids">{$_('embedding.drive_ids')}</Label>
<Label for="drive_ids">{i18n._('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}`) }}
onSelectedChange={(selected) => {
if (selected?.value) {
options.basemap = selected?.value;
}
}}
>
<Label for="basemap">{i18n._('embedding.basemap')}</Label>
<Select.Root type="single" bind:value={options.basemap}>
<Select.Trigger id="basemap" class="w-full h-8">
<Select.Value />
{i18n._(`layers.label.${options.basemap}`)}
</Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll">
{#each allowedEmbeddingBasemaps as basemap}
<Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
<Select.Item value={basemap}
>{i18n._(`layers.label.${basemap}`)}</Select.Item
>
{/each}
</Select.Content>
</Select.Root>
<div class="flex flex-row items-center gap-2">
<Label for="profile">{$_('menu.elevation_profile')}</Label>
<Label for="profile">{i18n._('menu.elevation_profile')}</Label>
<Checkbox id="profile" bind:checked={options.elevation.show} />
</div>
{#if options.elevation.show}
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
<Label class="flex flex-row items-center gap-2">
{$_('embedding.height')}
{i18n._('embedding.height')}
<Input
type="number"
bind:value={options.elevation.height}
@@ -138,73 +137,64 @@
</Label>
<div class="flex flex-row items-center gap-2">
<span class="shrink-0">
{$_('embedding.fill_by')}
{i18n._('embedding.fill_by')}
</span>
<Select.Root
selected={{ value: 'none', label: $_('embedding.none') }}
onSelectedChange={(selected) => {
let value = selected?.value;
if (value === 'none') {
options.elevation.fill = undefined;
} else if (
value === 'slope' ||
value === 'surface' ||
value === 'highway'
) {
options.elevation.fill = value;
}
}}
>
<Select.Root type="single" bind:value={options.elevation.fill}>
<Select.Trigger class="grow h-8">
<Select.Value />
{options.elevation.fill !== 'none'
? i18n._(`quantities.${options.elevation.fill}`)
: i18n._('embedding.none')}
</Select.Trigger>
<Select.Content>
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item
<Select.Item value="slope">{i18n._('quantities.slope')}</Select.Item
>
<Select.Item value="highway">{$_('quantities.highway')}</Select.Item
<Select.Item value="surface"
>{i18n._('quantities.surface')}</Select.Item
>
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
<Select.Item value="highway"
>{i18n._('quantities.highway')}</Select.Item
>
<Select.Item value="none">{i18n._('embedding.none')}</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="controls" bind:checked={options.elevation.controls} />
<Label for="controls">{$_('embedding.show_controls')}</Label>
<Label for="controls">{i18n._('embedding.show_controls')}</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
<Label for="show-speed" class="flex flex-row items-center gap-1">
<Zap size="16" />
{$_('quantities.speed')}
{i18n._('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" />
{$_('quantities.heartrate')}
{i18n._('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" />
{$_('quantities.cadence')}
{i18n._('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" />
{$_('quantities.temperature')}
{i18n._('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" />
{$_('quantities.power')}
{i18n._('quantities.power')}
</Label>
</div>
</div>
@@ -213,75 +203,75 @@
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
<Label for="distance-markers" class="flex flex-row items-center gap-1">
<Coins size="16" />
{$_('menu.distance_markers')}
{i18n._('menu.distance_markers')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
<Label for="direction-markers" class="flex flex-row items-center gap-1">
<Milestone size="16" />
{$_('menu.direction_markers')}
{i18n._('menu.direction_markers')}
</Label>
</div>
<div class="flex flex-row flex-wrap justify-between gap-3">
<Label class="flex flex-col items-start gap-2">
{$_('menu.distance_units')}
{i18n._('menu.distance_units')}
<RadioGroup.Root bind:value={options.distanceUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="metric" id="metric" />
<Label for="metric">{$_('menu.metric')}</Label>
<Label for="metric">{i18n._('menu.metric')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="imperial" id="imperial" />
<Label for="imperial">{$_('menu.imperial')}</Label>
<Label for="imperial">{i18n._('menu.imperial')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="nautical" id="nautical" />
<Label for="nautical">{$_('menu.nautical')}</Label>
<Label for="nautical">{i18n._('menu.nautical')}</Label>
</div>
</RadioGroup.Root>
</Label>
<Label class="flex flex-col items-start gap-2">
{$_('menu.velocity_units')}
{i18n._('menu.velocity_units')}
<RadioGroup.Root bind:value={options.velocityUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="speed" id="speed" />
<Label for="speed">{$_('quantities.speed')}</Label>
<Label for="speed">{i18n._('quantities.speed')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="pace" id="pace" />
<Label for="pace">{$_('quantities.pace')}</Label>
<Label for="pace">{i18n._('quantities.pace')}</Label>
</div>
</RadioGroup.Root>
</Label>
<Label class="flex flex-col items-start gap-2">
{$_('menu.temperature_units')}
{i18n._('menu.temperature_units')}
<RadioGroup.Root bind:value={options.temperatureUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="celsius" id="celsius" />
<Label for="celsius">{$_('menu.celsius')}</Label>
<Label for="celsius">{i18n._('menu.celsius')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
<Label for="fahrenheit">{$_('menu.fahrenheit')}</Label>
<Label for="fahrenheit">{i18n._('menu.fahrenheit')}</Label>
</div>
</RadioGroup.Root>
</Label>
</div>
<Label class="flex flex-col items-start gap-2">
{$_('menu.mode')}
{i18n._('menu.mode')}
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="system" id="system" />
<Label for="system">{$_('menu.system')}</Label>
<Label for="system">{i18n._('menu.system')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="light" id="light" />
<Label for="light">{$_('menu.light')}</Label>
<Label for="light">{i18n._('menu.light')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="dark" id="dark" />
<Label for="dark">{$_('menu.dark')}</Label>
<Label for="dark">{i18n._('menu.dark')}</Label>
</div>
</RadioGroup.Root>
</Label>
@@ -290,48 +280,48 @@
<Checkbox id="manual-camera" bind:checked={manualCamera} />
<Label for="manual-camera" class="flex flex-row items-center gap-1">
<Video size="16" />
{$_('embedding.manual_camera')}
{i18n._('embedding.manual_camera')}
</Label>
</div>
<p class="text-sm text-muted-foreground">
{$_('embedding.manual_camera_description')}
{i18n._('embedding.manual_camera_description')}
</p>
<div class="flex flex-row flex-wrap items-center gap-6">
<Label class="flex flex-col gap-1">
<span>{$_('embedding.latitude')}</span>
<span>{i18n._('embedding.latitude')}</span>
<span>{lat}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.longitude')}</span>
<span>{i18n._('embedding.longitude')}</span>
<span>{lon}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.zoom')}</span>
<span>{i18n._('embedding.zoom')}</span>
<span>{zoom}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.bearing')}</span>
<span>{i18n._('embedding.bearing')}</span>
<span>{bearing}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.pitch')}</span>
<span>{i18n._('embedding.pitch')}</span>
<span>{pitch}</span>
</Label>
</div>
</div>
<Label>
{$_('embedding.preview')}
{i18n._('embedding.preview')}
</Label>
<div class="relative h-[600px]">
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
<Embedding options={iframeOptions} bind:hash useHash={false} />
</div>
<Label>
{$_('embedding.code')}
{i18n._('embedding.code')}
</Label>
<pre
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
<code class="language-html">
{`<iframe src="https://gpx.studio${base}/embed?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(options)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
{`<iframe src="https://gpx.studio${base}/embed?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(iframeOptions)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
</code>
</pre>
</fieldset>

View File

@@ -2,22 +2,27 @@
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 { i18n } from '$lib/i18n.svelte';
export let files: string[];
export let ids: string[];
let {
files,
ids,
}: {
files: string[];
ids: string[];
} = $props();
</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.length > 0
href="{getURLForLanguage(i18n.lang, '/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')}
{i18n._('menu.open_in')}
<Logo class="h-[18px] xs:h-5 translate-y-[1px]" />
</Button>

View File

@@ -10,7 +10,7 @@ export type EmbeddingOptions = {
show: boolean;
height: number;
controls: boolean;
fill: 'slope' | 'surface' | 'highway' | undefined;
fill: 'slope' | 'surface' | 'highway' | 'none';
speed: boolean;
hr: boolean;
cad: boolean;
@@ -34,7 +34,7 @@ export const defaultEmbeddingOptions = {
show: true,
height: 170,
controls: true,
fill: undefined,
fill: 'none',
speed: false,
hr: false,
cad: false,
@@ -49,10 +49,6 @@ export const defaultEmbeddingOptions = {
theme: 'system',
};
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
}
export function getMergedEmbeddingOptions(
options: any,
defaultOptions: any = defaultEmbeddingOptions

View File

@@ -5,14 +5,12 @@
import { Separator } from '$lib/components/ui/separator';
import { Dialog } from 'bits-ui';
import {
currentTool,
exportAllFiles,
exportSelectedFiles,
ExportState,
exportState,
gpxStatistics,
} from '$lib/stores';
import { fileObservers } from '$lib/db';
} from '$lib/components/export/utils.svelte';
import { currentTool } from '$lib/components/toolbar/tools';
import {
Download,
Zap,
@@ -21,63 +19,70 @@
Orbit,
Thermometer,
SquareActivity,
} from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import { selection } from './file-list/Selection';
import { get } from 'svelte/store';
} from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { GPXStatistics } from 'gpx';
import { ListRootItem } from './file-list/FileList';
import { ListRootItem } from '$lib/components/file-list/file-list';
import { fileStateCollection } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection';
import { gpxStatistics } from '$lib/logic/statistics';
import { get } from 'svelte/store';
let open = false;
let exportOptions: Record<string, boolean> = {
let open = $derived(exportState.current !== ExportState.NONE);
let exportOptions: Record<string, boolean> = $state({
time: true,
hr: true,
cad: true,
atemp: true,
power: true,
extensions: true,
};
let hide: Record<string, boolean> = {
time: false,
hr: false,
cad: false,
atemp: false,
power: false,
extensions: false,
};
$: if ($exportState !== ExportState.NONE) {
open = true;
$currentTool = null;
let statistics = $gpxStatistics;
if ($exportState === ExportState.ALL) {
statistics = Array.from($fileObservers.values())
.map((file) => get(file)?.statistics)
.reduce((acc, cur) => {
if (cur !== undefined) {
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
}
return acc;
}, new GPXStatistics());
});
let hide: Record<string, boolean> = $derived.by(() => {
if (exportState.current === ExportState.NONE) {
return {
time: false,
hr: false,
cad: false,
atemp: false,
power: false,
extensions: false,
};
} else {
let statistics = $gpxStatistics;
if (exportState.current === ExportState.ALL) {
statistics = Array.from(get(fileStateCollection).values())
.map((file) => file.statistics)
.reduce((acc, cur) => {
if (cur !== undefined) {
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
}
return acc;
}, new GPXStatistics());
}
return {
time: statistics.global.time.total === 0,
hr: statistics.global.hr.count === 0,
cad: statistics.global.cad.count === 0,
atemp: statistics.global.atemp.count === 0,
power: statistics.global.power.count === 0,
extensions: Object.keys(statistics.global.extensions).length === 0,
};
}
});
let exclude = $derived(Object.keys(exportOptions).filter((key) => !exportOptions[key]));
hide.time = statistics.global.time.total === 0;
hide.hr = statistics.global.hr.count === 0;
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]);
$effect(() => {
if (open) {
currentTool.set(null);
}
});
</script>
<Dialog.Root
bind:open
onOpenChange={(isOpen) => {
if (!isOpen) {
$exportState = ExportState.NONE;
exportState.current = ExportState.NONE;
}
}}
>
@@ -87,36 +92,36 @@
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-secondary"
class="w-full flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 border rounded-md p-2 bg-secondary"
>
<span>⚠️</span>
<span class="max-w-[80%] text-sm">
{$_('menu.support_message')}
<span class="w-12 shrink-0 text-center text-xl">⚠️</span>
<span class="text-sm">
{i18n._('menu.support_message')}
</span>
</div>
<div class="w-full flex flex-row flex-wrap gap-2">
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
{$_('menu.support_button')}
<span class="ml-2">🙏</span>
{i18n._('menu.support_button')}
<span>🙏</span>
</Button>
<Button
variant="outline"
class="grow"
on:click={() => {
if ($exportState === ExportState.SELECTION) {
onclick={() => {
if (exportState.current === ExportState.SELECTION) {
exportSelectedFiles(exclude);
} else if ($exportState === ExportState.ALL) {
} else if (exportState.current === ExportState.ALL) {
exportAllFiles(exclude);
}
open = false;
$exportState = ExportState.NONE;
exportState.current = ExportState.NONE;
}}
>
<Download size="16" class="mr-1" />
{#if $fileObservers.size === 1 || ($exportState === ExportState.SELECTION && $selection.size === 1)}
{$_('menu.download_file')}
<Download size="16" />
{#if $fileStateCollection.size === 1 || (exportState.current === ExportState.SELECTION && $selection.size === 1)}
{i18n._('menu.download_file')}
{:else}
{$_('menu.download_files')}
{i18n._('menu.download_files')}
{/if}
</Button>
</div>
@@ -132,7 +137,7 @@
<Separator />
</div>
<Label class="shrink-0">
{$_('menu.export_options')}
{i18n._('menu.export_options')}
</Label>
<div class="grow">
<Separator />
@@ -143,7 +148,35 @@
<Checkbox id="export-time" bind:checked={exportOptions.time} />
<Label for="export-time" class="flex flex-row items-center gap-1">
<Zap size="16" />
{$_('quantities.time')}
{i18n._('quantities.time')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
<HeartPulse size="16" />
{i18n._('quantities.heartrate')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
<Label for="export-cadence" class="flex flex-row items-center gap-1">
<Orbit size="16" />
{i18n._('quantities.cadence')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
<Label for="export-temperature" class="flex flex-row items-center gap-1">
<Thermometer size="16" />
{i18n._('quantities.temperature')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
<Checkbox id="export-power" bind:checked={exportOptions.power} />
<Label for="export-power" class="flex flex-row items-center gap-1">
<SquareActivity size="16" />
{i18n._('quantities.power')}
</Label>
</div>
<div
@@ -152,35 +185,7 @@
<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' : ''}">
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
<HeartPulse size="16" />
{$_('quantities.heartrate')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
<Label for="export-cadence" class="flex flex-row items-center gap-1">
<Orbit size="16" />
{$_('quantities.cadence')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
<Label for="export-temperature" class="flex flex-row items-center gap-1">
<Thermometer size="16" />
{$_('quantities.temperature')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
<Checkbox id="export-power" bind:checked={exportOptions.power} />
<Label for="export-power" class="flex flex-row items-center gap-1">
<SquareActivity size="16" />
{$_('quantities.power')}
{i18n._('quantities.osm_extensions')}
</Label>
</div>
</div>

View File

@@ -0,0 +1,66 @@
import { selection } from '$lib/logic/selection';
import { fileStateCollection } from '$lib/logic/file-state';
import { settings } from '$lib/logic/settings';
import { buildGPX, type GPXFile } from 'gpx';
import FileSaver from 'file-saver';
import JSZip from 'jszip';
import { get } from 'svelte/store';
export enum ExportState {
NONE,
SELECTION,
ALL,
}
export const exportState = $state({
current: ExportState.NONE,
});
async function exportFiles(fileIds: string[], exclude: string[]) {
if (fileIds.length > 1) {
await exportFilesAsZip(fileIds, exclude);
} else {
const firstFileId = fileIds.at(0);
if (firstFileId != null) {
const file = fileStateCollection.getFile(firstFileId);
if (file) {
exportFile(file, exclude);
}
}
}
}
export async function exportSelectedFiles(exclude: string[]) {
const fileIds: string[] = [];
selection.applyToOrderedSelectedItemsFromFile(async (fileId, level, items) => {
fileIds.push(fileId);
});
await exportFiles(fileIds, exclude);
}
export async function exportAllFiles(exclude: string[]) {
await exportFiles(get(settings.fileOrder), exclude);
}
function exportFile(file: GPXFile, exclude: string[]) {
const blob = new Blob([buildGPX(file, exclude)], { type: 'application/gpx+xml' });
FileSaver.saveAs(blob, `${file.metadata.name}.gpx`);
}
async function exportFilesAsZip(fileIds: string[], exclude: string[]) {
const zip = new JSZip();
for (const fileId of fileIds) {
const file = fileStateCollection.getFile(fileId);
if (file) {
const gpx = buildGPX(file, exclude);
let filename = file.metadata.name;
for (let i = 1; zip.files[filename + '.gpx']; i++) {
filename = file.metadata.name + `-${i}`;
}
zip.file(filename + '.gpx', gpx);
}
}
if (Object.keys(zip.files).length > 0) {
const blob = await zip.generateAsync({ type: 'blob' });
FileSaver.saveAs(blob, 'gpx-files.zip');
}
}

View File

@@ -2,34 +2,33 @@
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import * as ContextMenu from '$lib/components/ui/context-menu';
import FileListNode from './FileListNode.svelte';
import { fileObservers, settings } from '$lib/db';
import { setContext } from 'svelte';
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './FileList';
import { copied, pasteSelection, selectAll, selection } from './Selection';
import { ClipboardPaste, FileStack, Plus } from 'lucide-svelte';
import { onMount, setContext } from 'svelte';
import { ListFileItem, ListLevel, ListRootItem } from './file-list';
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
import Shortcut from '$lib/components/Shortcut.svelte';
import { _ } from 'svelte-i18n';
import { createFile } from '$lib/stores';
import { i18n } from '$lib/i18n.svelte';
import { fileStateCollection } from '$lib/logic/file-state';
import { createFile, pasteSelection } from '$lib/logic/file-actions';
import { selection, copied } from '$lib/logic/selection';
import { allowedPastes } from './sortable-file-list';
export let orientation: 'vertical' | 'horizontal';
export let recursive = false;
let {
orientation,
recursive = false,
class: className = '',
style = '',
}: {
orientation: 'vertical' | 'horizontal';
recursive?: boolean;
class?: string;
style?: string;
} = $props();
setContext('orientation', orientation);
setContext('recursive', recursive);
const { treeFileView } = settings;
treeFileView.subscribe(($vertical) => {
if ($vertical) {
selection.update(($selection) => {
$selection.forEach((item) => {
if ($selection.hasAnyChildren(item, false)) {
$selection.toggle(item);
}
});
return $selection;
});
} else {
onMount(() => {
if (orientation === 'horizontal') {
selection.update(($selection) => {
$selection.forEach((item) => {
if (!(item instanceof ListFileItem)) {
@@ -46,29 +45,32 @@
<ScrollArea
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
{orientation}
scrollbarXClasses={orientation === 'vertical' ? '' : 'mt-1 h-2'}
scrollbarXClasses={orientation === 'vertical' ? '' : 'hidden'}
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
>
<div
class="flex {orientation === 'vertical'
? 'flex-col py-1 pl-1 min-h-screen'
: 'flex-row'} {$$props.class ?? ''}"
{...$$restProps}
: 'flex-row'} {className ?? ''}"
{style}
>
<FileListNode bind:node={$fileObservers} item={new ListRootItem()} />
<FileListNode node={$fileStateCollection} item={new ListRootItem()} />
{#if orientation === 'vertical'}
<ContextMenu.Root>
<ContextMenu.Trigger class="grow" />
<ContextMenu.Content>
<ContextMenu.Item on:click={createFile}>
<Plus size="16" class="mr-1" />
{$_('menu.new_file')}
<ContextMenu.Item onclick={createFile}>
<Plus size="16" />
{i18n._('menu.new_file')}
<Shortcut key="+" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item on:click={selectAll} disabled={$fileObservers.size === 0}>
<FileStack size="16" class="mr-1" />
{$_('menu.select_all')}
<ContextMenu.Item
onclick={() => selection.selectAll()}
disabled={$fileStateCollection.size === 0}
>
<FileStack size="16" />
{i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
@@ -76,10 +78,10 @@
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
on:click={pasteSelection}
onclick={pasteSelection}
>
<ClipboardPaste size="16" class="mr-1" />
{$_('menu.paste')}
<ClipboardPaste size="16" />
{i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} />
</ContextMenu.Item>
</ContextMenu.Content>

View File

@@ -1,483 +0,0 @@
import { dbUtils, getFile } from '$lib/db';
import { freeze } from 'immer';
import { GPXFile, Track, TrackSegment, Waypoint } from 'gpx';
import { selection } from './Selection';
import { newGPXFile } from '$lib/stores';
export enum ListLevel {
ROOT,
FILE,
TRACK,
SEGMENT,
WAYPOINTS,
WAYPOINT,
}
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
};
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.ROOT, ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
};
export abstract class ListItem {
level: ListLevel;
constructor(level: ListLevel) {
this.level = level;
}
abstract getId(): string | number;
abstract getFullId(): string;
abstract getIdAtLevel(level: ListLevel): string | number | undefined;
abstract getFileId(): string;
abstract getParent(): ListItem;
abstract extend(id: string | number): ListItem;
}
export class ListRootItem extends ListItem {
constructor() {
super(ListLevel.ROOT);
}
getId(): string {
return 'root';
}
getFullId(): string {
return 'root';
}
getIdAtLevel(level: ListLevel): string | number | undefined {
return undefined;
}
getFileId(): string {
return '';
}
getParent(): ListItem {
return this;
}
extend(id: string): ListFileItem {
return new ListFileItem(id);
}
}
export class ListFileItem extends ListItem {
fileId: string;
constructor(fileId: string) {
super(ListLevel.FILE);
this.fileId = fileId;
}
getId(): string {
return this.fileId;
}
getFullId(): string {
return this.fileId;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getParent(): ListItem {
return new ListRootItem();
}
extend(id: number | 'waypoints'): ListTrackItem | ListWaypointsItem {
if (id === 'waypoints') {
return new ListWaypointsItem(this.fileId);
} else {
return new ListTrackItem(this.fileId, id);
}
}
}
export class ListTrackItem extends ListItem {
fileId: string;
trackIndex: number;
constructor(fileId: string, trackIndex: number) {
super(ListLevel.TRACK);
this.fileId = fileId;
this.trackIndex = trackIndex;
}
getId(): number {
return this.trackIndex;
}
getFullId(): string {
return `${this.fileId}-track-${this.trackIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return this.trackIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getTrackIndex(): number {
return this.trackIndex;
}
getParent(): ListItem {
return new ListFileItem(this.fileId);
}
extend(id: number): ListTrackSegmentItem {
return new ListTrackSegmentItem(this.fileId, this.trackIndex, id);
}
}
export class ListTrackSegmentItem extends ListItem {
fileId: string;
trackIndex: number;
segmentIndex: number;
constructor(fileId: string, trackIndex: number, segmentIndex: number) {
super(ListLevel.SEGMENT);
this.fileId = fileId;
this.trackIndex = trackIndex;
this.segmentIndex = segmentIndex;
}
getId(): number {
return this.segmentIndex;
}
getFullId(): string {
return `${this.fileId}-track-${this.trackIndex}--${this.segmentIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return this.trackIndex;
case ListLevel.TRACK:
return this.segmentIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getTrackIndex(): number {
return this.trackIndex;
}
getSegmentIndex(): number {
return this.segmentIndex;
}
getParent(): ListItem {
return new ListTrackItem(this.fileId, this.trackIndex);
}
extend(): ListTrackSegmentItem {
return this;
}
}
export class ListWaypointsItem extends ListItem {
fileId: string;
constructor(fileId: string) {
super(ListLevel.WAYPOINTS);
this.fileId = fileId;
}
getId(): string {
return 'waypoints';
}
getFullId(): string {
return `${this.fileId}-waypoints`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return 'waypoints';
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getParent(): ListItem {
return new ListFileItem(this.fileId);
}
extend(id: number): ListWaypointItem {
return new ListWaypointItem(this.fileId, id);
}
}
export class ListWaypointItem extends ListItem {
fileId: string;
waypointIndex: number;
constructor(fileId: string, waypointIndex: number) {
super(ListLevel.WAYPOINT);
this.fileId = fileId;
this.waypointIndex = waypointIndex;
}
getId(): number {
return this.waypointIndex;
}
getFullId(): string {
return `${this.fileId}-waypoint-${this.waypointIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return 'waypoints';
case ListLevel.WAYPOINTS:
return this.waypointIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getWaypointIndex(): number {
return this.waypointIndex;
}
getParent(): ListItem {
return new ListWaypointsItem(this.fileId);
}
extend(): ListWaypointItem {
return this;
}
}
export function sortItems(items: ListItem[], reverse: boolean = false) {
items.sort((a, b) => {
if (a instanceof ListTrackItem && b instanceof ListTrackItem) {
return a.getTrackIndex() - b.getTrackIndex();
} else if (a instanceof ListTrackSegmentItem && b instanceof ListTrackSegmentItem) {
return a.getSegmentIndex() - b.getSegmentIndex();
} else if (a instanceof ListWaypointItem && b instanceof ListWaypointItem) {
return a.getWaypointIndex() - b.getWaypointIndex();
}
return a.level - b.level;
});
if (reverse) {
items.reverse();
}
}
export function moveItems(
fromParent: ListItem,
toParent: ListItem,
fromItems: ListItem[],
toItems: ListItem[],
remove: boolean = true
) {
if (fromItems.length === 0) {
return;
}
sortItems(fromItems, false);
sortItems(toItems, false);
let context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[] = [];
fromItems.forEach((item) => {
let file = getFile(item.getFileId());
if (file) {
if (item instanceof ListFileItem) {
context.push(file.clone());
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
context.push(file.trk[item.getTrackIndex()].clone());
} else if (
item instanceof ListTrackSegmentItem &&
item.getTrackIndex() < file.trk.length &&
item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length
) {
context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone());
} else if (item instanceof ListWaypointsItem) {
context.push(file.wpt.map((wpt) => wpt.clone()));
} else if (
item instanceof ListWaypointItem &&
item.getWaypointIndex() < file.wpt.length
) {
context.push(file.wpt[item.getWaypointIndex()].clone());
}
}
});
if (remove && !(fromParent instanceof ListRootItem)) {
sortItems(fromItems, true);
}
let files = [fromParent.getFileId(), toParent.getFileId()];
let callbacks = [
(file, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
fromItems.forEach((item) => {
if (item instanceof ListTrackItem) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
} else if (item instanceof ListTrackSegmentItem) {
file.replaceTrackSegments(
item.getTrackIndex(),
item.getSegmentIndex(),
item.getSegmentIndex(),
[]
);
} else if (item instanceof ListWaypointsItem) {
file.replaceWaypoints(0, file.wpt.length - 1, []);
} else if (item instanceof ListWaypointItem) {
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex(), []);
}
});
},
(file, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
toItems.forEach((item, i) => {
if (item instanceof ListTrackItem) {
if (context[i] instanceof Track) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
context[i],
]);
} else if (context[i] instanceof TrackSegment) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
new Track({
trkseg: [context[i]],
}),
]);
}
} else if (
item instanceof ListTrackSegmentItem &&
context[i] instanceof TrackSegment
) {
file.replaceTrackSegments(
item.getTrackIndex(),
item.getSegmentIndex(),
item.getSegmentIndex() - 1,
[context[i]]
);
} else if (item instanceof ListWaypointsItem) {
if (
Array.isArray(context[i]) &&
context[i].length > 0 &&
context[i][0] instanceof Waypoint
) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, context[i]);
} else if (context[i] instanceof Waypoint) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, [context[i]]);
}
} else if (item instanceof ListWaypointItem && context[i] instanceof Waypoint) {
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [
context[i],
]);
}
});
},
];
if (fromParent instanceof ListRootItem) {
files = [];
callbacks = [];
} else if (!remove) {
files.splice(0, 1);
callbacks.splice(0, 1);
}
dbUtils.applyEachToFilesAndGlobal(
files,
callbacks,
(files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
toItems.forEach((item, i) => {
if (item instanceof ListFileItem) {
if (context[i] instanceof GPXFile) {
let newFile = context[i];
if (remove) {
files.delete(newFile._data.id);
}
newFile._data.id = item.getFileId();
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof Track) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
if (context[i].name) {
newFile.metadata.name = context[i].name;
}
newFile.replaceTracks(0, 0, [context[i]]);
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof TrackSegment) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
newFile.replaceTracks(0, 0, [
new Track({
trkseg: [context[i]],
}),
]);
files.set(item.getFileId(), freeze(newFile));
}
}
});
},
context
);
selection.update(($selection) => {
$selection.clear();
toItems.forEach((item) => {
$selection.set(item, true);
});
return $selection;
});
}

View File

@@ -8,11 +8,10 @@
type GPXTreeElement,
} from 'gpx';
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { settings, type GPXFileWithStatistics } from '$lib/db';
import { get, type Readable } from 'svelte/store';
import { type Readable } from 'svelte/store';
import FileListNodeContent from './FileListNodeContent.svelte';
import FileListNodeLabel from './FileListNodeLabel.svelte';
import { afterUpdate, getContext } from 'svelte';
import { getContext } from 'svelte';
import {
ListFileItem,
ListTrackSegmentItem,
@@ -20,48 +19,50 @@
ListWaypointsItem,
type ListItem,
type ListTrackItem,
} from './FileList';
import { _ } from 'svelte-i18n';
import { selection } from './Selection';
} from './file-list';
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { selection } from '$lib/logic/selection';
export let node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
export let item: ListItem;
let {
node,
item,
}: {
node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
item: ListItem;
} = $props();
let recursive = getContext<boolean>('recursive');
let collapsible: CollapsibleTreeNode;
let collapsible: CollapsibleTreeNode | undefined = $state();
$: label =
let label = $derived(
node instanceof GPXFile && item instanceof ListFileItem
? node.metadata.name
: node instanceof Track
? (node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
? (node.name ?? `${i18n._('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
: node instanceof TrackSegment
? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
? `${i18n._('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
: node instanceof Waypoint
? (node.name ??
`${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
`${i18n._('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
: node instanceof GPXFile && item instanceof ListWaypointsItem
? $_('gpx.waypoints')
: '';
? i18n._('gpx.waypoints')
: ''
);
const { treeFileView } = settings;
function openIfSelectedChild() {
if (collapsible && get(treeFileView) && $selection.hasAnyChildren(item, false)) {
$effect(() => {
if (collapsible && $treeFileView && $selection.hasAnyChildren(item, false)) {
collapsible.openNode();
}
}
if ($selection) {
openIfSelectedChild();
}
afterUpdate(openIfSelectedChild);
});
</script>
{#if node instanceof Map}
@@ -72,12 +73,16 @@
<FileListNodeLabel {node} {item} {label} />
{:else if recursive}
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
<FileListNodeLabel {node} {item} {label} slot="trigger" />
<div slot="content" class="ml-2">
{#key node}
<FileListNodeContent {node} {item} />
{/key}
</div>
{#snippet trigger()}
<FileListNodeLabel {node} {item} {label} />
{/snippet}
{#snippet content()}
<div class="ml-4">
{#key node}
<FileListNodeContent {node} {item} />
{/key}
</div>
{/snippet}
</CollapsibleTreeNode>
{:else}
<FileListNodeLabel {node} {item} {label} />

View File

@@ -1,39 +1,29 @@
<script lang="ts" context="module">
let dragging: Writable<ListLevel | null> = writable(null);
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 { getContext, onDestroy, onMount } from 'svelte';
import { type Readable } 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';
import FileListNodeContent from './FileListNodeContent.svelte';
import { ListFileItem, ListLevel, ListWaypointsItem, type ListItem } from './file-list';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { allowedMoves, dragging, SortableFileList } from './sortable-file-list';
export let node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint;
export let item: ListItem;
export let waypointRoot: boolean = false;
let {
node,
item,
waypointRoot = false,
}: {
node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
item: ListItem;
waypointRoot?: boolean;
} = $props();
let container: HTMLElement;
let elements: { [id: string]: HTMLElement } = {};
let sortableLevel: ListLevel =
node instanceof Map
? ListLevel.FILE
@@ -46,255 +36,32 @@
: 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 canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel));
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')
);
});
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);
}
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();
}
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;
}
}
}
}
$: if ($fileOrder) {
syncFileOrder();
}
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;
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);
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');
}
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));
}
}
moveItems(fromItem, toItem, fromItems, toItems);
}
},
});
Object.defineProperty(sortable, '_item', {
value: item,
writable: true,
});
Object.defineProperty(sortable, '_waypointRoot', {
value: waypointRoot,
writable: true,
});
}
let sortable: SortableFileList;
onMount(() => {
createSortable();
destroyed = false;
sortable = new SortableFileList(
container,
node,
item,
waypointRoot,
sortableLevel,
orientation
);
});
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();
$effect(() => {
if (sortable && node) {
sortable.updateElements();
}
});
onDestroy(() => {
destroyed = true;
sortable.destroy();
});
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);
}
$: canDrop = $dragging !== null && allowedMoves[$dragging].includes(sortableLevel);
</script>
<div
@@ -340,11 +107,13 @@
{#if node instanceof GPXFile && item instanceof ListFileItem}
{#if !waypointRoot}
<svelte:self {node} {item} waypointRoot={true} />
<FileListNodeContent {node} {item} waypointRoot={true} />
{/if}
{/if}
<style lang="postcss">
@reference "../../../app.css";
.sortable > div {
@apply rounded-md;
@apply h-fit;

View File

@@ -2,7 +2,6 @@
import { Button } from '$lib/components/ui/button';
import * as ContextMenu from '$lib/components/ui/context-menu';
import Shortcut from '$lib/components/Shortcut.svelte';
import { dbUtils, getFile } from '$lib/db';
import {
Copy,
Info,
@@ -18,107 +17,107 @@
Maximize,
Scissors,
FileStack,
FileX,
} from 'lucide-svelte';
} from '@lucide/svelte';
import {
ListFileItem,
ListLevel,
ListTrackItem,
ListWaypointItem,
allowedPastes,
type ListItem,
} from './FileList';
import {
copied,
copySelection,
cut,
cutSelection,
pasteSelection,
selectAll,
selectItem,
selection,
} from './Selection';
} from './file-list';
import { getContext } from 'svelte';
import { get } from 'svelte/store';
import {
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';
import { i18n } from '$lib/i18n.svelte';
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte';
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { selection, copied, cut } from '$lib/logic/selection';
import { map } from '$lib/components/map/map';
import { fileActions, pasteSelection } from '$lib/logic/file-actions';
import { allHidden } from '$lib/logic/hidden';
import { boundsManager } from '$lib/logic/bounds';
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
import { fileStateCollection } from '$lib/logic/file-state';
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { allowedPastes } from './sortable-file-list';
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
export let item: ListItem;
export let label: string | undefined;
let {
node,
item,
label,
}: {
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
item: ListItem;
label: string | undefined;
} = $props();
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let embedding = getContext<boolean>('embedding');
$: singleSelection = $selection.size === 1;
let singleSelection = $derived($selection.size === 1);
let nodeColors: string[] = [];
let nodeColors: string[] = $state([]);
$: if (node && $map) {
nodeColors = [];
$effect.pre(() => {
let colors: string[] = [];
if (node && $map) {
if (node instanceof GPXFile) {
let defaultColor = undefined;
if (node instanceof GPXFile) {
let defaultColor = undefined;
let layer = gpxLayers.get(item.getFileId());
if (layer) {
defaultColor = layer.layerColor;
}
let style = node.getStyle(defaultColor);
style.color.forEach((c) => {
if (!nodeColors.includes(c)) {
nodeColors.push(c);
}
});
} else if (node instanceof Track) {
let style = node.getStyle();
if (style) {
if (style['gpx_style:color'] && !nodeColors.includes(style['gpx_style:color'])) {
nodeColors.push(style['gpx_style:color']);
}
}
if (nodeColors.length === 0) {
let layer = gpxLayers.get(item.getFileId());
let layer = gpxLayers.getLayer(item.getFileId());
if (layer) {
nodeColors.push(layer.layerColor);
defaultColor = layer.layerColor;
}
let style = node.getStyle(defaultColor);
style.color.forEach((c) => {
if (!colors.includes(c)) {
colors.push(c);
}
});
} else if (node instanceof Track) {
let style = node.getStyle();
if (style) {
if (style['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) {
colors.push(style['gpx_style:color']);
}
}
if (colors.length === 0) {
let layer = gpxLayers.getLayer(item.getFileId());
if (layer) {
colors.push(layer.layerColor);
}
}
}
}
}
nodeColors = colors;
});
$: symbolKey = node instanceof Waypoint ? getSymbolKey(node.sym) : undefined;
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
let openEditMetadata: boolean = false;
let openEditStyle: boolean = false;
let openEditMetadata: boolean = $derived(
editMetadata.current && singleSelection && $selection.has(item)
);
let openEditStyle: boolean = $derived(
editStyle.current &&
$selection.has(item) &&
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0
);
$: openEditMetadata = $editMetadata && singleSelection && $selection.has(item);
$: openEditStyle =
$editStyle &&
$selection.has(item) &&
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0;
$: hidden = item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden;
let hidden = $derived(
item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden
);
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<ContextMenu.Root
onOpenChange={(open) => {
if (open) {
if (!get(selection).has(item)) {
selectItem(item);
if (!$selection.has(item)) {
selection.selectItem(item);
}
}
}}
@@ -126,7 +125,7 @@
<ContextMenu.Trigger class="grow truncate">
<Button
variant="ghost"
class="relative w-full p-0 px-1 border-none overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
class="relative w-full p-0 overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
'vertical'
? 'h-fit'
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
@@ -148,7 +147,7 @@
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
)
.join(',')})"
/>
></div>
{/if}
<span
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
@@ -156,8 +155,8 @@
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
? 'text-muted-foreground'
: ''}"
on:contextmenu={(e) => {
if ($embedding) {
oncontextmenu={(e) => {
if (embedding) {
e.preventDefault();
e.stopPropagation();
return;
@@ -170,10 +169,10 @@
$selection = $selection;
}
}}
on:mouseenter={() => {
onmouseenter={() => {
if (item instanceof ListWaypointItem) {
let layer = gpxLayers.get(item.getFileId());
let file = getFile(item.getFileId());
let layer = gpxLayers.getLayer(item.getFileId());
let file = fileStateCollection.getFile(item.getFileId());
if (layer && file) {
let waypoint = file.wpt[item.getWaypointIndex()];
if (waypoint) {
@@ -185,9 +184,9 @@
}
}
}}
on:mouseleave={() => {
onmouseleave={() => {
if (item instanceof ListWaypointItem) {
let layer = gpxLayers.get(item.getFileId());
let layer = gpxLayers.getLayer(item.getFileId());
if (layer) {
waypointPopup?.setItem(null);
}
@@ -195,16 +194,13 @@
}}
>
{#if item.level === ListLevel.SEGMENT}
<Waypoints size="16" class="mr-1 shrink-0" />
<Waypoints size="16" class="mx-1 shrink-0" />
{:else if item.level === ListLevel.WAYPOINT}
{#if symbolKey && symbols[symbolKey].icon}
<svelte:component
this={symbols[symbolKey].icon}
size="16"
class="mr-1 shrink-0"
/>
{@const SymbolIcon = symbols[symbolKey].icon}
<SymbolIcon size="16" class="mx-1 shrink-0" />
{:else}
<MapPin size="16" class="mr-1 shrink-0" />
<MapPin size="16" class="mx-1 shrink-0" />
{/if}
{/if}
<span
@@ -216,13 +212,10 @@
</span>
{#if hidden}
<EyeOff
size="12"
class="shrink-0 mt-1 ml-1 {orientation === 'vertical'
? 'mr-2'
: ''} {item.level === ListLevel.SEGMENT ||
item.level === ListLevel.WAYPOINT
size="10"
class="shrink-0 size-3.5 ml-1 {orientation === 'vertical'
? 'mr-3'
: ''}"
: 'mt-0.5'}"
/>
{/if}
</span>
@@ -230,31 +223,34 @@
</ContextMenu.Trigger>
<ContextMenu.Content>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
<ContextMenu.Item disabled={!singleSelection} on:click={() => ($editMetadata = true)}>
<Info size="16" class="mr-1" />
{$_('menu.metadata.button')}
<ContextMenu.Item
disabled={!singleSelection}
onclick={() => (editMetadata.current = true)}
>
<Info size="16" />
{i18n._('menu.metadata.button')}
<Shortcut key="I" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item on:click={() => ($editStyle = true)}>
<PaintBucket size="16" class="mr-1" />
{$_('menu.style.button')}
<ContextMenu.Item onclick={() => (editStyle.current = true)}>
<PaintBucket size="16" />
{i18n._('menu.style.button')}
</ContextMenu.Item>
{/if}
<ContextMenu.Item
on:click={() => {
onclick={() => {
if ($allHidden) {
dbUtils.setHiddenToSelection(false);
fileActions.setHiddenToSelection(false);
} else {
dbUtils.setHiddenToSelection(true);
fileActions.setHiddenToSelection(true);
}
}}
>
{#if $allHidden}
<Eye size="16" class="mr-1" />
{$_('menu.unhide')}
<Eye size="16" />
{i18n._('menu.unhide')}
{:else}
<EyeOff size="16" class="mr-1" />
{$_('menu.hide')}
<EyeOff size="16" />
{i18n._('menu.hide')}
{/if}
<Shortcut key="H" ctrl={true} />
</ContextMenu.Item>
@@ -263,72 +259,68 @@
{#if item instanceof ListFileItem}
<ContextMenu.Item
disabled={!singleSelection}
on:click={() => dbUtils.addNewTrack(item.getFileId())}
onclick={() => fileActions.addNewTrack(item.getFileId())}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_track')}
<Plus size="16" />
{i18n._('menu.new_track')}
</ContextMenu.Item>
<ContextMenu.Separator />
{:else if item instanceof ListTrackItem}
<ContextMenu.Item
disabled={!singleSelection}
on:click={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())}
onclick={() =>
fileActions.addNewSegment(item.getFileId(), item.getTrackIndex())}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_segment')}
<Plus size="16" />
{i18n._('menu.new_segment')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
{/if}
{#if item.level !== ListLevel.WAYPOINTS}
<ContextMenu.Item on:click={selectAll}>
<FileStack size="16" class="mr-1" />
{$_('menu.select_all')}
<ContextMenu.Item onclick={() => selection.selectAll()}>
<FileStack size="16" />
{i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Item on:click={centerMapOnSelection}>
<Maximize size="16" class="mr-1" />
{$_('menu.center')}
<ContextMenu.Item onclick={() => boundsManager.centerMapOnSelection()}>
<Maximize size="16" />
{i18n._('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
>
<ContextMenu.Item onclick={fileActions.duplicateSelection}>
<Copy size="16" />
{i18n._('menu.duplicate')}
<Shortcut key="D" ctrl={true} />
</ContextMenu.Item>
{#if orientation === 'vertical'}
<ContextMenu.Item on:click={copySelection}>
<ClipboardCopy size="16" class="mr-1" />
{$_('menu.copy')}
<ContextMenu.Item onclick={() => selection.copySelection()}>
<ClipboardCopy size="16" />
{i18n._('menu.copy')}
<Shortcut key="C" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item on:click={cutSelection}>
<Scissors size="16" class="mr-1" />
{$_('menu.cut')}
<ContextMenu.Item onclick={() => selection.cutSelection()}>
<Scissors size="16" />
{i18n._('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}
onclick={pasteSelection}
>
<ClipboardPaste size="16" class="mr-1" />
{$_('menu.paste')}
<ClipboardPaste size="16" />
{i18n._('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" />
{$_('menu.close')}
{:else}
<Trash2 size="16" class="mr-1" />
{$_('menu.delete')}
{/if}
<ContextMenu.Item onclick={fileActions.deleteSelection}>
<Trash2 size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</ContextMenu.Item>
</ContextMenu.Content>

View File

@@ -2,12 +2,16 @@
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
import FileListNode from '$lib/components/file-list/FileListNode.svelte';
import type { GPXFileWithStatistics } from '$lib/db';
import { getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import { ListFileItem } from './FileList';
import { ListFileItem } from './file-list';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
export let file: Readable<GPXFileWithStatistics | undefined>;
let {
file,
}: {
file: Readable<GPXFileWithStatistics | undefined>;
} = $props();
let recursive = getContext<boolean>('recursive');
</script>

View File

@@ -1,375 +0,0 @@
import { get, writable } from 'svelte/store';
import {
ListFileItem,
ListItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
ListLevel,
sortItems,
ListWaypointsItem,
moveItems,
} from './FileList';
import { fileObservers, getFile, getFileIds, settings } from '$lib/db';
export class SelectionTreeType {
item: ListItem;
selected: boolean;
children: {
[key: string | number]: SelectionTreeType;
};
size: number = 0;
constructor(item: ListItem) {
this.item = item;
this.selected = false;
this.children = {};
}
clear() {
this.selected = false;
for (let key in this.children) {
this.children[key].clear();
}
this.size = 0;
}
_setOrToggle(item: ListItem, value?: boolean) {
if (item.level === this.item.level) {
let newSelected = value === undefined ? !this.selected : value;
if (this.selected !== newSelected) {
this.selected = newSelected;
this.size += this.selected ? 1 : -1;
}
} else {
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (!this.children.hasOwnProperty(id)) {
this.children[id] = new SelectionTreeType(this.item.extend(id));
}
this.size -= this.children[id].size;
this.children[id]._setOrToggle(item, value);
this.size += this.children[id].size;
}
}
}
set(item: ListItem, value: boolean) {
this._setOrToggle(item, value);
}
toggle(item: ListItem) {
this._setOrToggle(item);
}
has(item: ListItem): boolean {
if (item.level === this.item.level) {
return this.selected;
} else {
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].has(item);
}
}
}
return false;
}
hasAnyParent(item: ListItem, self: boolean = true): boolean {
if (
this.selected &&
this.item.level <= item.level &&
(self || this.item.level < item.level)
) {
return this.selected;
}
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].hasAnyParent(item, self);
}
}
return false;
}
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
if (
this.selected &&
this.item.level >= item.level &&
(self || this.item.level > item.level)
) {
return this.selected;
}
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (ignoreIds === undefined || ignoreIds.indexOf(id) === -1) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].hasAnyChildren(item, self, ignoreIds);
}
}
} else {
for (let key in this.children) {
if (ignoreIds === undefined || ignoreIds.indexOf(key) === -1) {
if (this.children[key].hasAnyChildren(item, self, ignoreIds)) {
return true;
}
}
}
}
return false;
}
getSelected(selection: ListItem[] = []): ListItem[] {
if (this.selected) {
selection.push(this.item);
}
for (let key in this.children) {
this.children[key].getSelected(selection);
}
return selection;
}
forEach(callback: (item: ListItem) => void) {
if (this.selected) {
callback(this.item);
}
for (let key in this.children) {
this.children[key].forEach(callback);
}
}
getChild(id: string | number): SelectionTreeType | undefined {
return this.children[id];
}
deleteChild(id: string | number) {
if (this.children.hasOwnProperty(id)) {
this.size -= this.children[id].size;
delete this.children[id];
}
}
}
export const selection = writable<SelectionTreeType>(new SelectionTreeType(new ListRootItem()));
export function selectItem(item: ListItem) {
selection.update(($selection) => {
$selection.clear();
$selection.set(item, true);
return $selection;
});
}
export function selectFile(fileId: string) {
selectItem(new ListFileItem(fileId));
}
export function addSelectItem(item: ListItem) {
selection.update(($selection) => {
$selection.toggle(item);
return $selection;
});
}
export function addSelectFile(fileId: string) {
addSelectItem(new ListFileItem(fileId));
}
export function selectAll() {
selection.update(($selection) => {
let item: ListItem = new ListRootItem();
$selection.forEach((i) => {
item = i;
});
if (item instanceof ListRootItem || item instanceof ListFileItem) {
$selection.clear();
get(fileObservers).forEach((_file, fileId) => {
$selection.set(new ListFileItem(fileId), true);
});
} else if (item instanceof ListTrackItem) {
let file = getFile(item.getFileId());
if (file) {
file.trk.forEach((_track, trackId) => {
$selection.set(new ListTrackItem(item.getFileId(), trackId), true);
});
}
} else if (item instanceof ListTrackSegmentItem) {
let file = getFile(item.getFileId());
if (file) {
file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => {
$selection.set(
new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId),
true
);
});
}
} else if (item instanceof ListWaypointItem) {
let file = getFile(item.getFileId());
if (file) {
file.wpt.forEach((_waypoint, waypointId) => {
$selection.set(new ListWaypointItem(item.getFileId(), waypointId), true);
});
}
}
return $selection;
});
}
export function getOrderedSelection(reverse: boolean = false): ListItem[] {
let selected: ListItem[] = [];
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
selected.push(...items);
}, reverse);
return selected;
}
export function applyToOrderedItemsFromFile(
selectedItems: ListItem[],
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
reverse: boolean = true
) {
get(settings.fileOrder).forEach((fileId) => {
let level: ListLevel | undefined = undefined;
let items: ListItem[] = [];
selectedItems.forEach((item) => {
if (item.getFileId() === fileId) {
level = item.level;
if (
item instanceof ListFileItem ||
item instanceof ListTrackItem ||
item instanceof ListTrackSegmentItem ||
item instanceof ListWaypointsItem ||
item instanceof ListWaypointItem
) {
items.push(item);
}
}
});
if (items.length > 0) {
sortItems(items, reverse);
callback(fileId, level, items);
}
});
}
export function applyToOrderedSelectedItemsFromFile(
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
reverse: boolean = true
) {
applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse);
}
export const copied = writable<ListItem[] | undefined>(undefined);
export const cut = writable(false);
export function copySelection(): boolean {
let selected = get(selection).getSelected();
if (selected.length > 0) {
copied.set(selected);
cut.set(false);
return true;
}
return false;
}
export function cutSelection() {
if (copySelection()) {
cut.set(true);
}
}
function resetCopied() {
copied.set(undefined);
cut.set(false);
}
export function pasteSelection() {
let fromItems = get(copied);
if (fromItems === undefined || fromItems.length === 0) {
return;
}
let selected = get(selection).getSelected();
if (selected.length === 0) {
selected = [new ListRootItem()];
}
let fromParent = fromItems[0].getParent();
let toParent = selected[selected.length - 1];
let startIndex: number | undefined = undefined;
if (fromItems[0].level === toParent.level) {
if (
toParent instanceof ListTrackItem ||
toParent instanceof ListTrackSegmentItem ||
toParent instanceof ListWaypointItem
) {
startIndex = toParent.getId() + 1;
}
toParent = toParent.getParent();
}
let toItems: ListItem[] = [];
if (toParent.level === ListLevel.ROOT) {
let fileIds = getFileIds(fromItems.length);
fileIds.forEach((fileId) => {
toItems.push(new ListFileItem(fileId));
});
} else {
let toFile = getFile(toParent.getFileId());
if (toFile) {
fromItems.forEach((item, index) => {
if (toParent instanceof ListFileItem) {
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
toItems.push(
new ListTrackItem(
toParent.getFileId(),
(startIndex ?? toFile.trk.length) + index
)
);
} else if (item instanceof ListWaypointsItem) {
toItems.push(new ListWaypointsItem(toParent.getFileId()));
} else if (item instanceof ListWaypointItem) {
toItems.push(
new ListWaypointItem(
toParent.getFileId(),
(startIndex ?? toFile.wpt.length) + index
)
);
}
} else if (toParent instanceof ListTrackItem) {
if (item instanceof ListTrackSegmentItem) {
let toTrackIndex = toParent.getTrackIndex();
toItems.push(
new ListTrackSegmentItem(
toParent.getFileId(),
toTrackIndex,
(startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index
)
);
}
} else if (toParent instanceof ListWaypointsItem) {
if (item instanceof ListWaypointItem) {
toItems.push(
new ListWaypointItem(
toParent.getFileId(),
(startIndex ?? toFile.wpt.length) + index
)
);
}
}
});
}
}
if (fromItems.length === toItems.length) {
moveItems(fromParent, toParent, fromItems, toItems, get(cut));
resetCopied();
}
}

View File

@@ -1,173 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label/index.js';
import { Slider } from '$lib/components/ui/slider';
import * as Popover from '$lib/components/ui/popover';
import { dbUtils, getFile, settings } from '$lib/db';
import { Save } from 'lucide-svelte';
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
import { selection } from './Selection';
import { editStyle, gpxLayers } from '$lib/stores';
import { _ } from 'svelte-i18n';
export let item: ListItem;
export let open = false;
const { defaultOpacity, defaultWidth } = settings;
let colors: string[] = [];
let color: string | undefined = undefined;
let opacity: number[] = [];
let width: number[] = [];
let colorChanged = false;
let opacityChanged = false;
let widthChanged = false;
function setStyleInputs() {
colors = [];
opacity = [];
width = [];
$selection.forEach((item) => {
if (item instanceof ListFileItem) {
let file = getFile(item.getFileId());
let layer = gpxLayers.get(item.getFileId());
if (file && layer) {
let style = file.getStyle();
style.color.push(layer.layerColor);
style.color.forEach((c) => {
if (!colors.includes(c)) {
colors.push(c);
}
});
style.opacity.forEach((o) => {
if (!opacity.includes(o)) {
opacity.push(o);
}
});
style.width.forEach((w) => {
if (!width.includes(w)) {
width.push(w);
}
});
}
} else if (item instanceof ListTrackItem) {
let file = getFile(item.getFileId());
let layer = gpxLayers.get(item.getFileId());
if (file && layer) {
let track = file.trk[item.getTrackIndex()];
let style = track.getStyle();
if (style) {
if (
style['gpx_style:color'] &&
!colors.includes(style['gpx_style:color'])
) {
colors.push(style['gpx_style:color']);
}
if (
style['gpx_style:opacity'] &&
!opacity.includes(style['gpx_style:opacity'])
) {
opacity.push(style['gpx_style:opacity']);
}
if (style['gpx_style:width'] && !width.includes(style['gpx_style:width'])) {
width.push(style['gpx_style:width']);
}
}
if (!colors.includes(layer.layerColor)) {
colors.push(layer.layerColor);
}
}
}
});
color = colors[0];
opacity = [opacity[0] ?? $defaultOpacity];
width = [width[0] ?? $defaultWidth];
colorChanged = false;
opacityChanged = false;
widthChanged = false;
}
$: if ($selection && open) {
setStyleInputs();
}
$: if (!open) {
$editStyle = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger />
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
<Label class="flex flex-row gap-2 items-center justify-between">
{$_('menu.style.color')}
<Input
bind:value={color}
type="color"
class="p-0 h-6 w-40"
on:change={() => (colorChanged = true)}
/>
</Label>
<Label class="flex flex-row gap-2 items-center justify-between">
{$_('menu.style.opacity')}
<div class="w-40 p-2">
<Slider
bind:value={opacity}
min={0.3}
max={1}
step={0.1}
onValueChange={() => (opacityChanged = true)}
/>
</div>
</Label>
<Label class="flex flex-row gap-2 items-center justify-between">
{$_('menu.style.width')}
<div class="w-40 p-2">
<Slider
bind:value={width}
id="width"
min={1}
max={10}
step={1}
onValueChange={() => (widthChanged = true)}
/>
</div>
</Label>
<Button
variant="outline"
disabled={!colorChanged && !opacityChanged && !widthChanged}
on:click={() => {
let style = {};
if (colorChanged) {
style['gpx_style:color'] = color;
}
if (opacityChanged) {
style['gpx_style:opacity'] = opacity[0];
}
if (widthChanged) {
style['gpx_style:width'] = width[0];
}
dbUtils.setStyleToSelection(style);
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
if (style['gpx_style:opacity']) {
$defaultOpacity = style['gpx_style:opacity'];
}
if (style['gpx_style:width']) {
$defaultWidth = style['gpx_style:width'];
}
}
open = false;
}}
>
<Save size="16" class="mr-1" />
{$_('menu.metadata.save')}
</Button>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,300 @@
export enum ListLevel {
ROOT,
FILE,
TRACK,
SEGMENT,
WAYPOINTS,
WAYPOINT,
}
export abstract class ListItem {
[x: string]: any;
level: ListLevel;
constructor(level: ListLevel) {
this.level = level;
}
abstract getId(): string | number;
abstract getFullId(): string;
abstract getIdAtLevel(level: ListLevel): string | number | undefined;
abstract getFileId(): string;
abstract getParent(): ListItem;
abstract extend(id: string | number): ListItem;
}
export class ListRootItem extends ListItem {
constructor() {
super(ListLevel.ROOT);
}
getId(): string {
return 'root';
}
getFullId(): string {
return 'root';
}
getIdAtLevel(level: ListLevel): string | number | undefined {
return undefined;
}
getFileId(): string {
return '';
}
getParent(): ListItem {
return this;
}
extend(id: string): ListFileItem {
return new ListFileItem(id);
}
}
export class ListFileItem extends ListItem {
fileId: string;
constructor(fileId: string) {
super(ListLevel.FILE);
this.fileId = fileId;
}
getId(): string {
return this.fileId;
}
getFullId(): string {
return this.fileId;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getParent(): ListItem {
return new ListRootItem();
}
extend(id: number | 'waypoints'): ListTrackItem | ListWaypointsItem {
if (id === 'waypoints') {
return new ListWaypointsItem(this.fileId);
} else {
return new ListTrackItem(this.fileId, id);
}
}
}
export class ListTrackItem extends ListItem {
fileId: string;
trackIndex: number;
constructor(fileId: string, trackIndex: number) {
super(ListLevel.TRACK);
this.fileId = fileId;
this.trackIndex = trackIndex;
}
getId(): number {
return this.trackIndex;
}
getFullId(): string {
return `${this.fileId}-track-${this.trackIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return this.trackIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getTrackIndex(): number {
return this.trackIndex;
}
getParent(): ListItem {
return new ListFileItem(this.fileId);
}
extend(id: number): ListTrackSegmentItem {
return new ListTrackSegmentItem(this.fileId, this.trackIndex, id);
}
}
export class ListTrackSegmentItem extends ListItem {
fileId: string;
trackIndex: number;
segmentIndex: number;
constructor(fileId: string, trackIndex: number, segmentIndex: number) {
super(ListLevel.SEGMENT);
this.fileId = fileId;
this.trackIndex = trackIndex;
this.segmentIndex = segmentIndex;
}
getId(): number {
return this.segmentIndex;
}
getFullId(): string {
return `${this.fileId}-track-${this.trackIndex}--${this.segmentIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return this.trackIndex;
case ListLevel.TRACK:
return this.segmentIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getTrackIndex(): number {
return this.trackIndex;
}
getSegmentIndex(): number {
return this.segmentIndex;
}
getParent(): ListItem {
return new ListTrackItem(this.fileId, this.trackIndex);
}
extend(): ListTrackSegmentItem {
return this;
}
}
export class ListWaypointsItem extends ListItem {
fileId: string;
constructor(fileId: string) {
super(ListLevel.WAYPOINTS);
this.fileId = fileId;
}
getId(): string {
return 'waypoints';
}
getFullId(): string {
return `${this.fileId}-waypoints`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return 'waypoints';
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getParent(): ListItem {
return new ListFileItem(this.fileId);
}
extend(id: number): ListWaypointItem {
return new ListWaypointItem(this.fileId, id);
}
}
export class ListWaypointItem extends ListItem {
fileId: string;
waypointIndex: number;
constructor(fileId: string, waypointIndex: number) {
super(ListLevel.WAYPOINT);
this.fileId = fileId;
this.waypointIndex = waypointIndex;
}
getId(): number {
return this.waypointIndex;
}
getFullId(): string {
return `${this.fileId}-waypoint-${this.waypointIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return 'waypoints';
case ListLevel.WAYPOINTS:
return this.waypointIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getWaypointIndex(): number {
return this.waypointIndex;
}
getParent(): ListItem {
return new ListWaypointsItem(this.fileId);
}
extend(): ListWaypointItem {
return this;
}
}
export function sortItems(items: ListItem[], reverse: boolean = false) {
items.sort((a, b) => {
if (a instanceof ListTrackItem && b instanceof ListTrackItem) {
return a.getTrackIndex() - b.getTrackIndex();
} else if (a instanceof ListTrackSegmentItem && b instanceof ListTrackSegmentItem) {
return a.getSegmentIndex() - b.getSegmentIndex();
} else if (a instanceof ListWaypointItem && b instanceof ListWaypointItem) {
return a.getWaypointIndex() - b.getWaypointIndex();
}
return a.level - b.level;
});
if (reverse) {
items.reverse();
}
}

View File

@@ -4,46 +4,56 @@
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 { Save } from '@lucide/svelte';
import { ListFileItem, ListTrackItem, type ListItem } from '../file-list';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { _ } from 'svelte-i18n';
import { editMetadata } from '$lib/stores';
import { i18n } from '$lib/i18n.svelte';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import { fileActionManager } from '$lib/logic/file-action-manager';
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
export let item: ListItem;
export let open = false;
let {
node,
item,
open = $bindable(),
}: {
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
item: ListItem;
open: boolean;
} = $props();
let name: string =
let name: string = $derived(
node instanceof GPXFile
? (node.metadata.name ?? '')
: node instanceof Track
? (node.name ?? '')
: '';
let description: string =
: ''
);
let description: string = $derived(
node instanceof GPXFile
? (node.metadata.desc ?? '')
: node instanceof Track
? (node.desc ?? '')
: '';
: ''
);
$: if (!open) {
$editMetadata = false;
}
$effect(() => {
if (!open) {
editMetadata.current = false;
}
});
</script>
<Popover.Root bind:open>
<Popover.Trigger />
<Popover.Trigger class="-mx-1" />
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
<Label for="name">{$_('menu.metadata.name')}</Label>
<Label for="name">{i18n._('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="font-semibold h-8" />
<Label for="description">{$_('menu.metadata.description')}</Label>
<Label for="description">{i18n._('menu.metadata.description')}</Label>
<Textarea bind:value={description} id="description" />
<Button
variant="outline"
on:click={() => {
dbUtils.applyToFile(item.getFileId(), (file) => {
onclick={() => {
fileActionManager.applyToFile(item.getFileId(), (file) => {
if (item instanceof ListFileItem && node instanceof GPXFile) {
file.metadata.name = name;
file.metadata.desc = description;
@@ -58,8 +68,8 @@
open = false;
}}
>
<Save size="16" class="mr-1" />
{$_('menu.metadata.save')}
<Save size="16" />
{i18n._('menu.metadata.save')}
</Button>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,3 @@
export const editMetadata = $state({
current: false,
});

View File

@@ -0,0 +1,284 @@
import { isMac } from '$lib/utils';
import Sortable, { type Direction } from 'sortablejs/Sortable';
import { ListItem, ListLevel, ListRootItem } from './file-list';
import { selection } from '$lib/logic/selection';
import { getFileIds, moveItems } from '$lib/logic/file-actions';
import { get, writable, type Readable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import type { AnyGPXTreeElement, GPXTreeElement, Waypoint } from 'gpx';
import { tick } from 'svelte';
const { fileOrder } = settings;
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
};
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.ROOT, ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
};
export const dragging = writable<ListLevel | null>(null);
export class SortableFileList {
private _node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
private _item: ListItem;
private _sortableLevel: ListLevel;
private _container: HTMLElement;
private _sortable: Sortable | null = null;
private _elements: { [id: string]: HTMLElement } = {};
private _updatingSelection: boolean = false;
private _unsubscribes: (() => void)[] = [];
constructor(
container: HTMLElement,
node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint,
item: ListItem,
waypointRoot: boolean,
sortableLevel: ListLevel,
orientation: Direction
) {
this._node = node;
this._item = item;
this._sortableLevel = sortableLevel;
this._container = container;
this._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: (e: Sortable.SortableEvent) =>
setTimeout(() => this.updateToSelection(e), 50),
onDeselect: (e: Sortable.SortableEvent) =>
setTimeout(() => this.updateToSelection(e), 50),
onStart: () => dragging.set(sortableLevel),
onEnd: () => dragging.set(null),
onSort: (e: Sortable.SortableEvent) => this.onSort(e),
});
Object.defineProperty(this._sortable, '_item', {
value: item,
writable: true,
});
Object.defineProperty(this._sortable, '_waypointRoot', {
value: waypointRoot,
writable: true,
});
this._unsubscribes.push(
selection.subscribe(() => tick().then(() => this.updateFromSelection()))
);
this._unsubscribes.push(fileOrder.subscribe(() => this.updateFromFileOrder()));
}
onSort(e: Sortable.SortableEvent) {
this.updateToFileOrder();
const from = Sortable.get(e.from);
const to = Sortable.get(e.to);
if (!from || !to) {
return;
}
let fromItem = from._item;
let toItem = to._item;
if (this._item === toItem && !(fromItem instanceof ListRootItem)) {
// Event is triggered on source and destination list, only handle it once
let fromItems = [];
let toItems = [];
if (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));
}
if (from._waypointRoot && to._waypointRoot) {
toItems = [toItem.extend('waypoints')];
} else {
if (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);
if (toItem instanceof ListRootItem) {
let newFileIds = getFileIds(newIndices.length);
toItems = newIndices.map((i, index) => {
get(fileOrder).splice(i, 0, newFileIds[index]);
return this._item.extend(newFileIds[index]);
});
} else {
toItems = newIndices.map((i) => toItem.extend(i));
}
}
moveItems(fromItem, toItem, fromItems, toItems);
}
}
updateFromSelection() {
const changed = this.getChangedIds();
if (changed.length === 0) {
return;
}
const selection_ = get(selection);
for (let id of changed) {
let element = this._elements[id];
if (element) {
if (selection_.has(this._item.extend(id))) {
Sortable.utils.select(element);
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
} else {
Sortable.utils.deselect(element);
}
}
}
}
updateToSelection(e: Sortable.SortableEvent) {
if (!this._sortable) return;
if (this._updatingSelection) return;
this._updatingSelection = true;
const changed = this.getChangedIds();
if (changed.length == 0) {
this._updatingSelection = false;
return;
}
selection.update(($selection) => {
$selection.clear();
Object.entries(this._elements).forEach(([id, element]) => {
$selection.set(
this._item.extend(this.getRealId(id)),
element.classList.contains('sortable-selected')
);
});
if (
e.originalEvent &&
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
($selection.size > 1 ||
!$selection.has(this._item.extend(this.getRealId(changed[0]))))
) {
// Fix bug that sometimes causes a single select to be treated as a multi-select
$selection.clear();
$selection.set(this._item.extend(this.getRealId(changed[0])), true);
}
return $selection;
});
this._updatingSelection = false;
}
updateFromFileOrder() {
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
return;
}
const fileOrder_ = get(fileOrder);
const sortableOrder = this._sortable.toArray();
if (
fileOrder_.length !== sortableOrder.length ||
fileOrder_.some((value, index) => value !== sortableOrder[index])
) {
this._sortable.sort(fileOrder_);
}
}
updateToFileOrder() {
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
return;
}
const fileOrder_ = get(fileOrder);
const sortableOrder = this._sortable.toArray();
if (
fileOrder_.length !== sortableOrder.length ||
fileOrder_.some((value, index) => value !== sortableOrder[index])
) {
fileOrder.set(sortableOrder);
}
}
updateElements() {
this._elements = {};
this._container.childNodes.forEach((element) => {
if (element instanceof HTMLElement) {
let attr = element.getAttribute('data-id');
if (attr) {
if (this._node instanceof Map && !this._node.has(attr)) {
element.remove();
} else {
this._elements[attr] = element;
}
}
}
});
}
destroy() {
this._sortable = null;
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
this._unsubscribes = [];
}
getChangedIds() {
let changed: (string | number)[] = [];
const selection_ = get(selection);
Object.entries(this._elements).forEach(([id, element]) => {
let realId = this.getRealId(id);
let realItem = this._item.extend(realId);
let inSelection = selection_.has(realItem);
let isSelected = element.classList.contains('sortable-selected');
if (inSelection !== isSelected) {
changed.push(realId);
}
});
return changed;
}
getRealId(id: string | number) {
return this._sortableLevel === ListLevel.FILE || this._sortableLevel === ListLevel.WAYPOINTS
? id
: parseInt(id as string);
}
}

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label/index.js';
import { Slider } from '$lib/components/ui/slider';
import * as Popover from '$lib/components/ui/popover';
import { Save } from '@lucide/svelte';
import {
ListFileItem,
ListTrackItem,
type ListItem,
} from '$lib/components/file-list/file-list';
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { i18n } from '$lib/i18n.svelte';
import type { LineStyleExtension } from 'gpx';
import { settings } from '$lib/logic/settings';
import { selection } from '$lib/logic/selection';
import { fileStateCollection } from '$lib/logic/file-state';
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
import { untrack } from 'svelte';
import { fileActions } from '$lib/logic/file-actions';
let {
item,
open = $bindable(),
}: {
item: ListItem;
open: boolean;
} = $props();
const { defaultOpacity, defaultWidth } = settings;
let color: string = $state('');
let opacity: number = $state(0);
let width: number = $state(0);
let colorChanged = $state(false);
let opacityChanged = $state(false);
let widthChanged = $state(false);
function setStyleInputs() {
opacity = $defaultOpacity;
width = $defaultWidth;
$selection.forEach((item) => {
if (item instanceof ListFileItem) {
let file = fileStateCollection.getFile(item.getFileId());
let layer = gpxLayers.getLayer(item.getFileId());
if (file && layer) {
let style = file.getStyle();
color = layer.layerColor;
if (style.opacity.length > 0) {
opacity = style.opacity[0];
}
if (style.width.length > 0) {
width = style.width[0];
}
}
} else if (item instanceof ListTrackItem) {
let file = fileStateCollection.getFile(item.getFileId());
let layer = gpxLayers.getLayer(item.getFileId());
if (file && layer) {
color = layer.layerColor;
let track = file.trk[item.getTrackIndex()];
let style = track.getStyle();
if (style) {
if (style['gpx_style:color']) {
color = style['gpx_style:color'];
}
if (style['gpx_style:opacity']) {
opacity = style['gpx_style:opacity'];
}
if (style['gpx_style:width']) {
width = style['gpx_style:width'];
}
}
}
}
});
colorChanged = false;
opacityChanged = false;
widthChanged = false;
}
$effect(() => {
if ($selection && open) {
untrack(() => setStyleInputs());
}
});
$effect(() => {
if (!open) {
editStyle.current = false;
}
});
function applyStyle() {
let style: LineStyleExtension = {};
if (colorChanged) {
style['gpx_style:color'] = color;
}
if (opacityChanged) {
style['gpx_style:opacity'] = opacity;
}
if (widthChanged) {
style['gpx_style:width'] = width;
}
fileActions.setStyleToSelection(style);
if (item instanceof ListFileItem && $selection.size === fileStateCollection.size) {
if (style['gpx_style:opacity']) {
$defaultOpacity = style['gpx_style:opacity'];
}
if (style['gpx_style:width']) {
$defaultWidth = style['gpx_style:width'];
}
}
open = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger class="-mx-1" />
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
<Label class="flex flex-row gap-2 items-center justify-between">
{i18n._('menu.style.color')}
<Input
bind:value={color}
type="color"
class="p-0 h-6 w-40"
onchange={() => (colorChanged = true)}
/>
</Label>
<Label class="flex flex-row gap-2 items-center justify-between">
{i18n._('menu.style.opacity')}
<div class="w-40 p-2">
<Slider
bind:value={opacity}
min={0.3}
max={1}
step={0.1}
onValueChange={() => (opacityChanged = true)}
type="single"
/>
</div>
</Label>
<Label class="flex flex-row gap-2 items-center justify-between">
{i18n._('menu.style.width')}
<div class="w-40 p-2">
<Slider
bind:value={width}
id="width"
min={1}
max={10}
step={1}
onValueChange={() => (widthChanged = true)}
type="single"
/>
</div>
</Label>
<Button
variant="outline"
disabled={!colorChanged && !opacityChanged && !widthChanged}
onclick={applyStyle}
>
<Save size="16" />
{i18n._('menu.metadata.save')}
</Button>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,3 @@
export const editStyle = $state({
current: false,
});

View File

@@ -1,23 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { ClipboardCopy } from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import type { Coordinates } from 'gpx';
export let coordinates: Coordinates;
export let onCopy: () => void = () => {};
</script>
<Button
class="w-full px-2 py-1 h-8 justify-start {$$props.class}"
variant="outline"
on:click={() => {
navigator.clipboard.writeText(
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}`
);
onCopy();
}}
>
<ClipboardCopy size="16" class="mr-1" />
{$_('menu.copy_coordinates')}
</Button>

View File

@@ -1,56 +0,0 @@
<script lang="ts">
import { map, gpxLayers } from '$lib/stores';
import { GPXLayer } from './GPXLayer';
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;
$: if ($map && $fileObservers) {
// remove layers for deleted files
gpxLayers.forEach((layer, fileId) => {
if (!$fileObservers.has(fileId)) {
layer.remove();
gpxLayers.delete(fileId);
} else if ($map !== layer.map) {
layer.updateMap($map);
}
});
// add layers for new files
$fileObservers.forEach((file, fileId) => {
if (!gpxLayers.has(fileId)) {
gpxLayers.set(fileId, new GPXLayer($map, fileId, file));
}
});
}
$: if ($map) {
if (distanceMarkers) {
distanceMarkers.remove();
}
if (startEndMarkers) {
startEndMarkers.remove();
}
createPopups($map);
distanceMarkers = new DistanceMarkers($map);
startEndMarkers = new StartEndMarkers($map);
}
onDestroy(() => {
gpxLayers.forEach((layer) => layer.remove());
gpxLayers.clear();
removePopups();
if (distanceMarkers) {
distanceMarkers.remove();
distanceMarkers = undefined;
}
if (startEndMarkers) {
startEndMarkers.remove();
startEndMarkers = undefined;
}
});
</script>

View File

@@ -1,20 +0,0 @@
<script lang="ts">
import LayerTreeNode from './LayerTreeNode.svelte';
import { type LayerTreeType } from '$lib/assets/layers';
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
export let layerTree: LayerTreeType;
export let name: string;
export let selected: string | undefined = undefined;
export let multiple: boolean = false;
export let checked: LayerTreeType = {};
</script>
<form>
<fieldset class="min-w-64 mb-1">
<CollapsibleTree nohover={true}>
<LayerTreeNode {name} node={layerTree} bind:selected {multiple} bind:checked />
</CollapsibleTree>
</fieldset>
</form>

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { map } from '$lib/stores';
import { trackpointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
import { map } from '$lib/components/map/map';
import { trackpointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { TrackPoint } from 'gpx';
$: if ($map) {
$map.on('contextmenu', (e) => {
map.onLoad((map_) => {
map_.on('contextmenu', (e) => {
trackpointPopup?.setItem({
item: new TrackPoint({
attributes: {
@@ -14,5 +14,5 @@
}),
});
});
}
});
</script>

View File

@@ -0,0 +1,239 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
import { Button } from '$lib/components/ui/button';
import { i18n } from '$lib/i18n.svelte';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { page } from '$app/state';
import { map } from '$lib/components/map/map';
let {
accessToken = PUBLIC_MAPBOX_TOKEN,
geolocate = true,
geocoder = true,
hash = true,
class: className = '',
}: {
accessToken?: string;
geolocate?: boolean;
geocoder?: boolean;
hash?: boolean;
class?: string;
} = $props();
mapboxgl.accessToken = accessToken;
let webgl2Supported = $state(true);
let embeddedApp = $state(false);
onMount(() => {
let gl = document.createElement('canvas').getContext('webgl2');
if (!gl) {
webgl2Supported = false;
return;
}
if (window.top !== window.self && !page.route.id?.includes('embed')) {
embeddedApp = true;
return;
}
let language = page.params.language;
if (language === 'zh') {
language = 'zh-Hans';
} else if (language?.includes('-')) {
language = language.split('-')[0];
} else if (language === '' || language === undefined) {
language = 'en';
}
map.init(PUBLIC_MAPBOX_TOKEN, language, hash, geocoder, geolocate);
});
onDestroy(() => {
map.destroy();
});
</script>
<div class={className}>
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
<div
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported &&
!embeddedApp
? 'hidden'
: ''} {embeddedApp ? 'z-30' : ''}"
>
{#if !webgl2Supported}
<p>{i18n._('webgl2_required')}</p>
<Button href="https://get.webgl.org/webgl2/" target="_blank">
{i18n._('enable_webgl2')}
</Button>
{:else if embeddedApp}
<p>The app cannot be embedded in an iframe.</p>
<Button href="https://gpx.studio/help/integration" target="_blank">
Learn how to create a map for your website
</Button>
{/if}
</div>
</div>
<style lang="postcss">
@reference "../../../app.css";
div :global(.mapboxgl-map) {
@apply font-sans;
}
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
@apply shadow-md;
@apply bg-background;
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-icon) {
@apply dark:brightness-[4.7];
}
div :global(.mapboxgl-ctrl-geocoder) {
@apply flex;
@apply flex-row;
@apply w-fit;
@apply min-w-fit;
@apply items-center;
@apply shadow-md;
}
div :global(.suggestions) {
@apply shadow-md;
@apply bg-background;
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
@apply text-foreground;
@apply hover:text-accent-foreground;
@apply hover:bg-accent;
}
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
@apply bg-background;
}
div :global(.mapboxgl-ctrl-geocoder--button) {
@apply bg-transparent;
@apply hover:bg-transparent;
}
div :global(.mapboxgl-ctrl-geocoder--icon) {
@apply fill-foreground;
@apply hover:fill-accent-foreground;
}
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
@apply relative;
@apply top-0;
@apply left-0;
@apply my-2;
@apply w-[29px];
}
div :global(.mapboxgl-ctrl-geocoder--input) {
@apply relative;
@apply w-64;
@apply py-0;
@apply pl-2;
@apply focus:outline-none;
@apply transition-[width];
@apply duration-200;
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
@apply w-0;
@apply p-0;
}
div :global(.mapboxgl-ctrl-top-right) {
@apply z-40;
@apply flex;
@apply flex-col;
@apply items-end;
@apply h-full;
@apply overflow-hidden;
}
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
@apply bottom-[42px];
}
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
@apply bottom-[42px];
}
div :global(.mapboxgl-ctrl-attrib) {
@apply dark:bg-transparent;
}
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
@apply dark:bg-background;
}
div :global(.mapboxgl-ctrl-attrib-button) {
@apply dark:bg-foreground;
}
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
@apply dark:bg-foreground;
}
div :global(.mapboxgl-ctrl-attrib a) {
@apply text-foreground;
}
div :global(.mapboxgl-popup) {
@apply w-fit;
@apply z-50;
}
div :global(.mapboxgl-popup-content) {
@apply p-0;
@apply bg-transparent;
@apply shadow-none;
}
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
@apply border-r-background;
}
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
@apply border-l-background;
}
</style>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { TrackPoint, Waypoint } from 'gpx';
import WaypointPopup from '$lib/components/map/gpx-layer/WaypointPopup.svelte';
import TrackpointPopup from '$lib/components/map/gpx-layer/TrackpointPopup.svelte';
import OverpassPopup from '$lib/components/map/layer-control/OverpassPopup.svelte';
import type { PopupItem } from '$lib/components/map/map-popup';
import type { Writable } from 'svelte/store';
let {
item,
onContainerReady,
}: { item: Writable<PopupItem | null>; onContainerReady: (div: HTMLDivElement) => void } =
$props();
let container: HTMLDivElement | null = $state(null);
$effect(() => {
if (container) {
onContainerReady(container);
}
});
</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,38 @@
<script lang="ts">
import CustomControl from './custom-control';
import { map } from '$lib/components/map/map';
import { onMount, type Snippet } from 'svelte';
let {
position = 'top-right',
class: className = '',
children,
}: {
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
class: string;
children: Snippet;
} = $props();
let container: HTMLDivElement;
let control: CustomControl | null = null;
onMount(() => {
map.onLoad((map: mapboxgl.Map) => {
if (position.includes('right')) container.classList.add('float-right');
else container.classList.add('float-left');
container.classList.remove('hidden');
if (control === null) {
control = new CustomControl(container);
}
map.addControl(control, position);
});
});
</script>
<div
bind:this={container}
class="{className ||
''} clear-both translate-0 m-[10px] mb-0 last:mb-[10px] pointer-events-auto bg-background rounded shadow-md hidden"
>
{@render children()}
</div>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { ClipboardCopy } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import type { Coordinates } from 'gpx';
let {
coordinates,
onCopy = () => {},
class: className = '',
}: {
coordinates: Coordinates;
onCopy?: () => void;
class?: string;
} = $props();
</script>
<Button
class="p-1 has-[>svg]:px-2 h-8 justify-start {className}"
variant="outline"
onclick={() => {
navigator.clipboard.writeText(
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}`
);
onCopy();
}}
>
<ClipboardCopy size="16" />
{i18n._('menu.copy_coordinates')}
</Button>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
import { DistanceMarkers } from '$lib/components/map/gpx-layer/distance-markers';
import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-markers';
import { createPopups, removePopups } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { map } from '$lib/components/map/map';
let distanceMarkers: DistanceMarkers;
let startEndMarkers: StartEndMarkers;
onMount(() => {
gpxLayers.init();
startEndMarkers = new StartEndMarkers();
distanceMarkers = new DistanceMarkers();
});
map.onLoad((map_) => {
createPopups(map_);
});
onDestroy(() => {
if (startEndMarkers) {
startEndMarkers.remove();
}
if (distanceMarkers) {
distanceMarkers.remove();
}
removePopups();
});
</script>

View File

@@ -1,20 +1,16 @@
<script lang="ts">
import type { TrackPoint } from 'gpx';
import type { PopupItem } from '$lib/components/MapPopup';
import CopyCoordinates from '$lib/components/gpx-layer/CopyCoordinates.svelte';
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
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';
import { _ } from 'svelte-i18n';
import { Compass, Mountain, Timer } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import type { PopupItem } from '$lib/components/map/map-popup';
export let trackpoint: PopupItem<TrackPoint>;
let { trackpoint }: { trackpoint: PopupItem<TrackPoint> } = $props();
</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" />
@@ -31,7 +27,7 @@
{#if trackpoint.item.time}
<div class="flex flex-row items-center gap-1">
<Timer size="14" />
{df.format(trackpoint.item.time)}
{i18n.df.format(trackpoint.item.time)}
</div>
{/if}
<CopyCoordinates

View File

@@ -2,21 +2,25 @@
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import Shortcut from '$lib/components/Shortcut.svelte';
import CopyCoordinates from '$lib/components/gpx-layer/CopyCoordinates.svelte';
import { deleteWaypoint } from './GPXLayerPopup';
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
import WithUnits from '$lib/components/WithUnits.svelte';
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
import { Tool, currentTool } from '$lib/stores';
import { Dot, ExternalLink, Trash2 } from '@lucide/svelte';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { _ } from 'svelte-i18n';
import { i18n } from '$lib/i18n.svelte';
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';
import { fileActions } from '$lib/logic/file-actions';
import type { PopupItem } from '$lib/components/map/map-popup';
export let waypoint: PopupItem<Waypoint>;
let {
waypoint,
}: {
waypoint: PopupItem<Waypoint>;
} = $props();
$: symbolKey = waypoint ? getSymbolKey(waypoint.item.sym) : undefined;
let symbolKey = $derived(waypoint ? getSymbolKey(waypoint.item.sym) : undefined);
function sanitize(text: string | undefined): string {
if (text === undefined) {
@@ -32,8 +36,8 @@
}
</script>
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
<Card.Header class="p-0">
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0">
<Card.Header class="p-0 gap-0">
<Card.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">
@@ -41,7 +45,7 @@
<ExternalLink size="12" class="inline-block mb-1.5" />
</a>
{:else}
{waypoint.item.name ?? $_('gpx.waypoint')}
{waypoint.item.name ?? i18n._('gpx.waypoint')}
{/if}
</Card.Title>
</Card.Header>
@@ -50,15 +54,12 @@
{#if symbolKey}
<span>
{#if symbols[symbolKey].icon}
<svelte:component
this={symbols[symbolKey].icon}
size="12"
class="inline-block mb-0.5"
/>
{@const Icon = symbols[symbolKey].icon}
<Icon size="12" class="inline-block mb-1" />
{:else}
<span class="w-4 inline-block" />
<span class="w-4 inline-block"></span>
{/if}
{$_(`gpx.symbol.${symbolKey}`)}
{i18n._(`gpx.symbol.${symbolKey}`)}
</span>
<Dot size="16" />
{/if}
@@ -70,7 +71,7 @@
<WithUnits value={waypoint.item.ele} type="elevation" />
{/if}
</div>
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
<ScrollArea class="flex flex-col max-h-[30dvh]">
{#if waypoint.item.desc}
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
{/if}
@@ -82,12 +83,17 @@
<CopyCoordinates coordinates={waypoint.item.attributes} />
{#if $currentTool === Tool.WAYPOINT}
<Button
class="w-full px-2 py-1 h-8 justify-start"
class="p-1 has-[>svg]:px-2 h-8"
variant="outline"
on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
onclick={() => {
if (waypoint.fileId) {
fileActions.deleteWaypoint(waypoint.fileId, waypoint.item._data.index);
waypoint.hide?.();
}
}}
>
<Trash2 size="16" class="mr-1" />
{$_('menu.delete')}
<Trash2 size="16" />
{i18n._('menu.delete')}
<Shortcut shift={true} click={true} />
</Button>
{/if}
@@ -96,6 +102,8 @@
</Card.Root>
<style lang="postcss">
@reference "../../../../app.css";
div :global(a) {
@apply text-link;
@apply hover:underline;

View File

@@ -1,6 +1,10 @@
import { settings } from '$lib/db';
import { gpxStatistics } from '$lib/stores';
import { settings } from '$lib/logic/settings';
import { gpxStatistics } from '$lib/logic/statistics';
import { getConvertedDistanceToKilometers } from '$lib/units';
import type { GeoJSONSource } from 'mapbox-gl';
import { get } from 'svelte/store';
import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden';
const { distanceMarkers, distanceUnits } = settings;
@@ -14,34 +18,41 @@ const stops = [
];
export class DistanceMarkers {
map: mapboxgl.Map;
updateBinded: () => void = this.update.bind(this);
unsubscribes: (() => void)[] = [];
constructor(map: mapboxgl.Map) {
this.map = map;
constructor() {
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.import.load', this.updateBinded);
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
this.unsubscribes.push(
map.subscribe((map_) => {
if (map_) {
map_.on('style.import.load', this.updateBinded);
}
})
);
}
update() {
const map_ = get(map);
if (!map_) return;
try {
if (get(distanceMarkers)) {
let distanceSource = this.map.getSource('distance-markers');
if (get(distanceMarkers) && !get(allHidden)) {
let distanceSource: GeoJSONSource | undefined = map_.getSource('distance-markers');
if (distanceSource) {
distanceSource.setData(this.getDistanceMarkersGeoJSON());
} else {
this.map.addSource('distance-markers', {
map_.addSource('distance-markers', {
type: 'geojson',
data: this.getDistanceMarkersGeoJSON(),
});
}
stops.forEach(([d, minzoom, maxzoom]) => {
if (!this.map.getLayer(`distance-markers-${d}`)) {
this.map.addLayer({
if (!map_.getLayer(`distance-markers-${d}`)) {
map_.addLayer({
id: `distance-markers-${d}`,
type: 'symbol',
source: 'distance-markers',
@@ -67,13 +78,13 @@ export class DistanceMarkers {
},
});
} else {
this.map.moveLayer(`distance-markers-${d}`);
map_.moveLayer(`distance-markers-${d}`);
}
});
} else {
stops.forEach(([d]) => {
if (this.map.getLayer(`distance-markers-${d}`)) {
this.map.removeLayer(`distance-markers-${d}`);
if (map_.getLayer(`distance-markers-${d}`)) {
map_.removeLayer(`distance-markers-${d}`);
}
});
}
@@ -95,7 +106,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)
getConvertedDistanceToKilometers(currentTargetDistance)
) {
let distance = currentTargetDistance.toFixed(0);
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [

View File

@@ -1,5 +1,4 @@
import { dbUtils } from '$lib/db';
import { MapPopup } from '$lib/components/MapPopup';
import { MapPopup } from '$lib/components/map/map-popup';
export let waypointPopup: MapPopup | null = null;
export let trackpointPopup: MapPopup | null = null;
@@ -38,7 +37,3 @@ export function removePopups() {
trackpointPopup = null;
}
}
export function deleteWaypoint(fileId: string, waypointIndex: number) {
dbUtils.applyToFile(fileId, (file) => file.replaceWaypoints(waypointIndex, waypointIndex, []));
}

View File

@@ -1,9 +1,7 @@
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 { waypointPopup, deleteWaypoint, trackpointPopup } from './GPXLayerPopup';
import { addSelectItem, selectItem, selection } from '$lib/components/file-list/Selection';
import { map } from '$lib/components/map/map';
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
import {
ListTrackSegmentItem,
ListWaypointItem,
@@ -11,18 +9,19 @@ import {
ListTrackItem,
ListFileItem,
ListRootItem,
} from '$lib/components/file-list/FileList';
import {
getClosestLinePoint,
getElevation,
resetCursor,
setGrabbingCursor,
setPointerCursor,
setScissorsCursor,
} from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/Waypoint.svelte';
} from '$lib/components/file-list/file-list';
import { getClosestLinePoint, getElevation } from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
import { MapPin, Square } from 'lucide-static';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { selection } from '$lib/logic/selection';
import { settings } from '$lib/logic/settings';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { fileActions } from '$lib/logic/file-actions';
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
const colors = [
'#ff0000',
@@ -85,7 +84,6 @@ function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
const { directionMarkers, treeFileView, defaultOpacity, defaultWidth } = settings;
export class GPXLayer {
map: mapboxgl.Map;
fileId: string;
file: Readable<GPXFileWithStatistics | undefined>;
layerColor: string;
@@ -101,15 +99,18 @@ export class GPXLayer {
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
constructor(
map: mapboxgl.Map,
fileId: string,
file: Readable<GPXFileWithStatistics | undefined>
) {
this.map = map;
constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
this.fileId = fileId;
this.file = file;
this.layerColor = getColor();
this.unsubscribe.push(
map.subscribe(($map) => {
if ($map) {
$map.on('style.import.load', this.updateBinded);
this.update();
}
})
);
this.unsubscribe.push(file.subscribe(this.updateBinded));
this.unsubscribe.push(
selection.subscribe(($selection) => {
@@ -136,13 +137,12 @@ export class GPXLayer {
})
);
this.draggable = get(currentTool) === Tool.WAYPOINT;
this.map.on('style.import.load', this.updateBinded);
}
update() {
const _map = get(map);
let file = get(this.file)?.file;
if (!file) {
if (!_map || !file) {
return;
}
@@ -156,18 +156,18 @@ export class GPXLayer {
}
try {
let source = this.map.getSource(this.fileId);
let source = _map.getSource(this.fileId) as mapboxgl.GeoJSONSource | undefined;
if (source) {
source.setData(this.getGeoJSON());
} else {
this.map.addSource(this.fileId, {
_map.addSource(this.fileId, {
type: 'geojson',
data: this.getGeoJSON(),
});
}
if (!this.map.getLayer(this.fileId)) {
this.map.addLayer({
if (!_map.getLayer(this.fileId)) {
_map.addLayer({
id: this.fileId,
type: 'line',
source: this.fileId,
@@ -182,16 +182,16 @@ 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('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
this.map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
_map.on('click', this.fileId, this.layerOnClickBinded);
_map.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
_map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
_map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
_map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
}
if (get(directionMarkers)) {
if (!this.map.getLayer(this.fileId + '-direction')) {
this.map.addLayer(
if (!_map.getLayer(this.fileId + '-direction')) {
_map.addLayer(
{
id: this.fileId + '-direction',
type: 'symbol',
@@ -213,12 +213,12 @@ export class GPXLayer {
'text-halo-color': 'white',
},
},
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
_map.getLayer('distance-markers-100') ? 'distance-markers-100' : undefined
);
}
} else {
if (this.map.getLayer(this.fileId + '-direction')) {
this.map.removeLayer(this.fileId + '-direction');
if (_map.getLayer(this.fileId + '-direction')) {
_map.removeLayer(this.fileId + '-direction');
}
}
@@ -229,7 +229,7 @@ export class GPXLayer {
}
});
this.map.setFilter(
_map.setFilter(
this.fileId,
[
'any',
@@ -241,8 +241,8 @@ export class GPXLayer {
],
{ validate: false }
);
if (this.map.getLayer(this.fileId + '-direction')) {
this.map.setFilter(
if (_map.getLayer(this.fileId + '-direction')) {
_map.setFilter(
this.fileId + '-direction',
[
'any',
@@ -300,7 +300,7 @@ export class GPXLayer {
}
if (get(currentTool) === Tool.WAYPOINT && e.shiftKey) {
deleteWaypoint(this.fileId, marker._waypoint._data.index);
fileActions.deleteWaypoint(this.fileId, marker._waypoint._data.index);
e.stopPropagation();
return;
}
@@ -313,11 +313,11 @@ export class GPXLayer {
false
)
) {
addSelectItem(
selection.addSelectItem(
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
);
} else {
selectItem(
selection.selectItem(
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
);
}
@@ -329,15 +329,15 @@ export class GPXLayer {
e.stopPropagation();
});
marker.on('dragstart', () => {
setGrabbingCursor();
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, true);
marker.getElement().style.cursor = 'grabbing';
waypointPopup?.hide();
});
marker.on('dragend', (e) => {
resetCursor();
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
marker.getElement().style.cursor = '';
getElevation([marker._waypoint]).then((ele) => {
dbUtils.applyToFile(this.fileId, (file) => {
fileActionManager.applyToFile(this.fileId, (file) => {
let latLng = marker.getLngLat();
let wpt = file.wpt[marker._waypoint._data.index];
wpt.setCoordinates({
@@ -362,36 +362,31 @@ export class GPXLayer {
this.markers.forEach((marker) => {
if (!marker._waypoint._data.hidden) {
marker.addTo(this.map);
marker.addTo(_map);
} else {
marker.remove();
}
});
}
updateMap(map: mapboxgl.Map) {
this.map = map;
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('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
this.map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
this.map.off('style.import.load', this.updateBinded);
const _map = get(map);
if (_map) {
_map.off('click', this.fileId, this.layerOnClickBinded);
_map.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
_map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
_map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
_map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
_map.off('style.import.load', this.updateBinded);
if (this.map.getLayer(this.fileId + '-direction')) {
this.map.removeLayer(this.fileId + '-direction');
if (_map.getLayer(this.fileId + '-direction')) {
_map.removeLayer(this.fileId + '-direction');
}
if (this.map.getLayer(this.fileId)) {
this.map.removeLayer(this.fileId);
if (_map.getLayer(this.fileId)) {
_map.removeLayer(this.fileId);
}
if (this.map.getSource(this.fileId)) {
this.map.removeSource(this.fileId);
if (_map.getSource(this.fileId)) {
_map.removeSource(this.fileId);
}
}
@@ -405,14 +400,15 @@ export class GPXLayer {
}
moveToFront() {
if (this.map.getLayer(this.fileId)) {
this.map.moveLayer(this.fileId);
const _map = get(map);
if (!_map) {
return;
}
if (this.map.getLayer(this.fileId + '-direction')) {
this.map.moveLayer(
this.fileId + '-direction',
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
);
if (_map.getLayer(this.fileId)) {
_map.moveLayer(this.fileId);
}
if (_map.getLayer(this.fileId + '-direction')) {
_map.moveLayer(this.fileId + '-direction');
}
}
@@ -426,14 +422,15 @@ export class GPXLayer {
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
) {
setScissorsCursor();
mapCursor.notify(MapCursorState.SCISSORS, true);
} else {
setPointerCursor();
mapCursor.notify(MapCursorState.LAYER_HOVER, true);
}
}
layerOnMouseLeave() {
resetCursor();
mapCursor.notify(MapCursorState.SCISSORS, false);
mapCursor.notify(MapCursorState.LAYER_HOVER, false);
}
layerOnMouseMove(e: any) {
@@ -469,7 +466,7 @@ export class GPXLayer {
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
) {
dbUtils.split(this.fileId, trackIndex, segmentIndex, {
fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, {
lat: e.lngLat.lat,
lon: e.lngLat.lng,
});
@@ -493,9 +490,9 @@ export class GPXLayer {
}
if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
addSelectItem(item);
selection.addSelectItem(item);
} else {
selectItem(item);
selection.selectItem(item);
}
}

View File

@@ -0,0 +1,44 @@
import { GPXFileStateCollectionObserver } from '$lib/logic/file-state';
import { GPXLayer } from './gpx-layer';
export class GPXLayerCollection {
private _layers: Map<string, GPXLayer>;
private _fileStateCollectionObserver: GPXFileStateCollectionObserver | null = null;
constructor() {
this._layers = new Map<string, GPXLayer>();
}
init() {
if (this._fileStateCollectionObserver) {
return;
}
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
(newFiles) => {
newFiles.forEach((fileState, fileId) => {
const layer = new GPXLayer(fileId, fileState);
this._layers.set(fileId, layer);
});
},
(fileId) => {
const layer = this._layers.get(fileId);
if (layer) {
layer.remove();
this._layers.delete(fileId);
}
},
() => {
this._layers.forEach((layer) => {
layer.remove();
});
this._layers.clear();
}
);
}
getLayer(fileId: string): GPXLayer | undefined {
return this._layers.get(fileId);
}
}
export const gpxLayers = new GPXLayerCollection();

View File

@@ -1,17 +1,17 @@
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from '$lib/stores';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
import mapboxgl from 'mapbox-gl';
import { get } from 'svelte/store';
import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden';
export class StartEndMarkers {
map: mapboxgl.Map;
start: mapboxgl.Marker;
end: mapboxgl.Marker;
updateBinded: () => void = this.update.bind(this);
unsubscribes: (() => void)[] = [];
constructor(map: mapboxgl.Map) {
this.map = map;
constructor() {
let startElement = document.createElement('div');
let endElement = document.createElement('div');
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
@@ -22,21 +22,27 @@ export class StartEndMarkers {
this.start = new mapboxgl.Marker({ element: startElement });
this.end = new mapboxgl.Marker({ element: endElement });
map.onLoad(() => this.update());
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
}
update() {
let tool = get(currentTool);
let statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING) {
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(this.map);
const map_ = get(map);
if (!map_) return;
const tool = get(currentTool);
const statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
const hidden = get(allHidden);
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING && !hidden) {
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(map_);
this.end
.setLngLat(
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
)
.addTo(this.map);
.addTo(map_);
} else {
this.start.remove();
this.end.remove();

View File

@@ -16,14 +16,14 @@
Move,
Map,
Layers2,
} from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import { settings } from '$lib/db';
} from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
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';
import { onMount } from 'svelte';
import { customBasemapUpdate, isSelected, remove } from './utils';
import { settings } from '$lib/logic/settings';
import { map } from '$lib/components/map/map';
import { dndzone } from 'svelte-dnd-action';
const {
customLayers,
@@ -37,17 +37,23 @@
customOverlayOrder,
} = settings;
let name: string = '';
let tileUrls: string[] = [''];
let maxZoom: number = 20;
let layerType: 'basemap' | 'overlay' = 'basemap';
let resourceType: 'raster' | 'vector' = 'raster';
let name: string = $state('');
let tileUrls: string[] = $state(['']);
let maxZoom: number = $state(20);
let layerType: 'basemap' | 'overlay' = $state('basemap');
let resourceType: 'raster' | 'vector' = $derived.by(() => {
if (tileUrls[0].length > 0) {
if (
tileUrls[0].includes('.json') ||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
) {
return 'vector';
}
}
return 'raster';
});
let basemapContainer: HTMLElement;
let overlayContainer: HTMLElement;
let basemapSortable: Sortable;
let overlaySortable: Sortable;
let selectedLayerId: string | undefined = $state(undefined);
onMount(() => {
if ($customBasemapOrder.length === 0) {
@@ -60,45 +66,30 @@
(id) => $customLayers[id].layerType === 'overlay'
);
}
basemapSortable = Sortable.create(basemapContainer, {
onSort: (e) => {
$customBasemapOrder = basemapSortable.toArray();
$selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {});
},
});
overlaySortable = Sortable.create(overlayContainer, {
onSort: (e) => {
$customOverlayOrder = overlaySortable.toArray();
$selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {});
},
});
basemapSortable.sort($customBasemapOrder);
overlaySortable.sort($customOverlayOrder);
});
onDestroy(() => {
basemapSortable.destroy();
overlaySortable.destroy();
});
let customBasemapItems: {
id: string;
name: string;
}[] = $derived(
$customBasemapOrder.map((id) => ({
id: id,
name: $customLayers[id].name,
}))
);
let customOverlayItems: {
id: string;
name: string;
}[] = $derived(
$customOverlayOrder.map((id) => ({
id: id,
name: $customLayers[id].name,
}))
);
$: if (tileUrls[0].length > 0) {
if (
tileUrls[0].includes('.json') ||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
) {
resourceType = 'vector';
} else {
resourceType = 'raster';
}
}
$effect(() => {
setDataFromSelectedLayer(selectedLayerId);
});
function createLayer() {
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
@@ -185,11 +176,7 @@
return $tree;
});
if (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
if ($map && $currentOverlays && isSelected($currentOverlays, layerId)) {
try {
$map.removeImport(layerId);
} catch (e) {
@@ -197,10 +184,13 @@
}
}
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
$currentOverlays.overlays['custom'] = {};
}
$currentOverlays.overlays['custom'][layerId] = true;
currentOverlays.update(($overlays) => {
if (!$overlays.overlays.hasOwnProperty('custom')) {
$overlays.overlays['custom'] = {};
}
$overlays.overlays['custom'][layerId] = true;
return $overlays;
});
if (!$customOverlayOrder.includes(layerId)) {
$customOverlayOrder = [...$customOverlayOrder, layerId];
@@ -225,58 +215,22 @@
$previousBasemap = defaultBasemap;
}
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
$selectedBasemapTree.basemaps['custom'],
layerId
);
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
$selectedBasemapTree.basemaps = tryDeleteLayer(
$selectedBasemapTree.basemaps,
'custom'
);
}
$selectedBasemapTree = remove($selectedBasemapTree, layerId);
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
} else {
$currentOverlays.overlays['custom'][layerId] = false;
if ($previousOverlays.overlays['custom']) {
$previousOverlays.overlays['custom'] = tryDeleteLayer(
$previousOverlays.overlays['custom'],
layerId
);
}
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
$selectedOverlayTree.overlays['custom'],
layerId
);
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
$selectedOverlayTree.overlays = tryDeleteLayer(
$selectedOverlayTree.overlays,
'custom'
);
if ($currentOverlays) {
$currentOverlays = remove($currentOverlays, layerId);
}
$previousOverlays = remove($previousOverlays, layerId);
$selectedOverlayTree = remove($selectedOverlayTree, layerId);
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
if (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
}
$customLayers = tryDeleteLayer($customLayers, layerId);
}
let selectedLayerId: string | undefined = undefined;
function setDataFromSelectedLayer() {
if (selectedLayerId) {
const layer = $customLayers[selectedLayerId];
function setDataFromSelectedLayer(layerId?: string) {
if (layerId) {
const layer = $customLayers[layerId];
name = layer.name;
tileUrls = layer.tileUrls;
maxZoom = layer.maxZoom;
@@ -290,32 +244,60 @@
resourceType = 'raster';
}
}
$: selectedLayerId, setDataFromSelectedLayer();
</script>
<div class="flex flex-col">
{#if $customBasemapOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Map size="16" />
{$_('layers.label.basemaps')}
{i18n._('layers.label.basemaps')}
<div class="grow">
<Separator />
</div>
</div>
{/if}
<div
bind:this={basemapContainer}
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
use:dndzone={{
items: customBasemapItems,
type: 'basemap',
dropTargetStyle: {},
transformDraggedElement: (element) => {
if (element) {
element.style.opacity = '0.5';
}
},
}}
onconsider={(e) => {
customBasemapItems = e.detail.items;
}}
onfinalize={(e) => {
customBasemapItems = e.detail.items;
$customBasemapOrder = customBasemapItems.map((item) => item.id);
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce((acc, item) => {
acc[item.id] = true;
return acc;
}, {});
}}
>
{#each $customBasemapOrder as id (id)}
<div class="flex flex-row items-center gap-2" data-id={id}>
{#each customBasemapItems as item (item.id)}
<div class="flex flex-row items-center gap-2">
<Move size="12" />
<span class="grow">{$customLayers[id].name}</span>
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
<span class="grow">{item.name}</span>
<Button
variant="outline"
size="icon-sm"
onclick={() => (selectedLayerId = item.id)}
class="p-1 h-7"
>
<Pencil size="16" />
</Button>
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
<Button
variant="outline"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
@@ -324,56 +306,85 @@
{#if $customOverlayOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Layers2 size="16" />
{$_('layers.label.overlays')}
{i18n._('layers.label.overlays')}
<div class="grow">
<Separator />
</div>
</div>
{/if}
<div
bind:this={overlayContainer}
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
use:dndzone={{
items: customOverlayItems,
type: 'overlay',
dropTargetStyle: {},
transformDraggedElement: (element) => {
if (element) {
element.style.opacity = '0.5';
}
},
}}
onconsider={(e) => {
customOverlayItems = e.detail.items;
}}
onfinalize={(e) => {
customOverlayItems = e.detail.items;
$customOverlayOrder = customOverlayItems.map((item) => item.id);
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce((acc, item) => {
acc[item.id] = true;
return acc;
}, {});
}}
>
{#each $customOverlayOrder as id (id)}
<div class="flex flex-row items-center gap-2" data-id={id}>
{#each customOverlayItems as item (item.id)}
<div class="flex flex-row items-center gap-2">
<Move size="12" />
<span class="grow">{$customLayers[id].name}</span>
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
<span class="grow">{item.name}</span>
<Button
variant="outline"
size="icon-sm"
onclick={() => (selectedLayerId = item.id)}
class="p-1 h-7"
>
<Pencil size="16" />
</Button>
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
<Button
variant="outline"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
<Card.Root>
<Card.Root class="py-0 gap-0 shadow-none">
<Card.Header class="p-3">
<Card.Title class="text-base">
{#if selectedLayerId}
{$_('layers.custom_layers.edit')}
{i18n._('layers.custom_layers.edit')}
{:else}
{$_('layers.custom_layers.new')}
{i18n._('layers.custom_layers.new')}
{/if}
</Card.Title>
</Card.Header>
<Card.Content class="p-3 pt-0">
<fieldset class="flex flex-col gap-2">
<Label for="name">{$_('menu.metadata.name')}</Label>
<Label for="name">{i18n._('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="h-8" />
<Label for="url">{$_('layers.custom_layers.urls')}</Label>
<Label for="url">{i18n._('layers.custom_layers.urls')}</Label>
{#each tileUrls as url, i}
<div class="flex flex-row gap-2">
<Input
bind:value={tileUrls[i]}
id="url"
class="h-8"
placeholder={$_('layers.custom_layers.url_placeholder')}
placeholder={i18n._('layers.custom_layers.url_placeholder')}
/>
{#if tileUrls.length > 1}
<Button
on:click={() =>
onclick={() =>
(tileUrls = tileUrls.filter((_, index) => index !== i))}
variant="outline"
class="p-1 h-8"
@@ -383,7 +394,7 @@
{/if}
{#if i === tileUrls.length - 1}
<Button
on:click={() => (tileUrls = [...tileUrls, ''])}
onclick={() => (tileUrls = [...tileUrls, ''])}
variant="outline"
class="p-1 h-8"
>
@@ -393,7 +404,7 @@
</div>
{/each}
{#if resourceType === 'raster'}
<Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
<Label for="maxZoom">{i18n._('layers.custom_layers.max_zoom')}</Label>
<Input
type="number"
bind:value={maxZoom}
@@ -403,31 +414,31 @@
class="h-8"
/>
{/if}
<Label>{$_('layers.custom_layers.layer_type')}</Label>
<Label>{i18n._('layers.custom_layers.layer_type')}</Label>
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="basemap" id="basemap" />
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
<Label for="basemap">{i18n._('layers.custom_layers.basemap')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="overlay" id="overlay" />
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
<Label for="overlay">{i18n._('layers.custom_layers.overlay')}</Label>
</div>
</RadioGroup.Root>
{#if selectedLayerId}
<div class="mt-2 flex flex-row gap-2">
<Button variant="outline" on:click={createLayer} class="grow">
<Save size="16" class="mr-1" />
{$_('layers.custom_layers.update')}
<Button variant="outline" onclick={createLayer} class="grow">
<Save size="16" />
{i18n._('layers.custom_layers.update')}
</Button>
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
<Button variant="outline" onclick={() => (selectedLayerId = undefined)}>
<CircleX size="16" />
</Button>
</div>
{:else}
<Button variant="outline" class="mt-2" on:click={createLayer}>
<CirclePlus size="16" class="mr-1" />
{$_('layers.custom_layers.create')}
<Button variant="outline" class="mt-2" onclick={createLayer}>
<CirclePlus size="16" />
{i18n._('layers.custom_layers.create')}
</Button>
{/if}
</fieldset>

View File

@@ -1,18 +1,16 @@
<script lang="ts">
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
import CustomControl from '$lib/components/map/custom-control/CustomControl.svelte';
import LayerTree from './LayerTree.svelte';
import { OverpassLayer } from './overpass-layer';
import { Separator } from '$lib/components/ui/separator';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { Layers } from 'lucide-svelte';
import { Layers } from '@lucide/svelte';
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
import { settings } from '$lib/db';
import { map } from '$lib/stores';
import { get, writable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
import { map } from '$lib/components/map/map';
import { customBasemapUpdate, getLayers } from './utils';
import { OverpassLayer } from './OverpassLayer';
import type { ImportSpecification, StyleSpecification } from 'mapbox-gl';
import { untrack } from 'svelte';
let container: HTMLDivElement;
let overpassLayer: OverpassLayer;
@@ -30,30 +28,37 @@
} = settings;
function setStyle() {
if ($map) {
let basemap = basemaps.hasOwnProperty($currentBasemap)
? basemaps[$currentBasemap]
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
$map.removeImport('basemap');
if (typeof basemap === 'string') {
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
} else {
$map.addImport(
{
id: 'basemap',
data: basemap,
},
'overlays'
);
}
if (!$map) {
return;
}
let basemap = basemaps.hasOwnProperty($currentBasemap)
? basemaps[$currentBasemap]
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
$map.removeImport('basemap');
if (typeof basemap === 'string') {
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
} else {
$map.addImport(
{
id: 'basemap',
url: '',
data: basemap as StyleSpecification,
},
'overlays'
);
}
}
$: if ($map && ($currentBasemap || $customBasemapUpdate)) {
setStyle();
}
$effect(() => {
if ($map && ($currentBasemap || $customBasemapUpdate)) {
untrack(() => setStyle());
}
});
function addOverlay(id: string) {
if (!$map) {
return;
}
try {
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
if (typeof overlay === 'string') {
@@ -62,7 +67,7 @@
if ($opacities.hasOwnProperty(id)) {
overlay = {
...overlay,
layers: overlay.layers.map((layer) => {
layers: (overlay as StyleSpecification).layers.map((layer) => {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
@@ -75,7 +80,8 @@
}
$map.addImport({
id,
data: overlay,
url: '',
data: overlay as StyleSpecification,
});
}
} catch (e) {
@@ -87,15 +93,26 @@
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 activeOverlays =
$map
.getStyle()
.imports?.reduce(
(
acc: Record<string, ImportSpecification>,
imprt: ImportSpecification
) => {
if (
!['basemap', 'overlays', 'glyphs-and-sprite'].includes(imprt.id)
) {
acc[imprt.id] = imprt;
}
return acc;
},
{}
) || {};
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
toRemove.forEach((id) => {
$map.removeImport(id);
$map?.removeImport(id);
});
let toAdd = Object.entries(overlayLayers)
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
@@ -109,52 +126,44 @@
}
}
$: if ($map && $currentOverlays && $opacities) {
updateOverlays();
}
$effect(() => {
if ($map && $currentOverlays && $opacities) {
untrack(() => updateOverlays());
}
});
$: if ($map) {
map.onLoad((_map: mapboxgl.Map) => {
if (overpassLayer) {
overpassLayer.remove();
}
overpassLayer = new OverpassLayer($map);
overpassLayer = new OverpassLayer(_map);
overpassLayer.add();
$map.on('style.import.load', updateOverlays);
}
let selectedBasemap = writable(get(currentBasemap));
selectedBasemap.subscribe((value) => {
// Updates coming from radio buttons
if (value !== get(currentBasemap)) {
previousBasemap.set(get(currentBasemap));
currentBasemap.set(value);
}
});
currentBasemap.subscribe((value) => {
// Updates coming from the database, or from the user swapping basemaps
if (value !== get(selectedBasemap)) {
selectedBasemap.set(value);
}
let first = true;
_map.on('style.import.load', () => {
if (!first) return;
first = false;
updateOverlays();
});
});
let open = false;
let open = $state(false);
function openLayerControl() {
open = true;
}
function closeLayerControl() {
open = false;
}
let cancelEvents = false;
let cancelEvents = $state(false);
</script>
<CustomControl class="group min-w-[29px] min-h-[29px] overflow-hidden">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={container}
class="h-full w-full"
on:mouseenter={openLayerControl}
on:mouseleave={closeLayerControl}
on:pointerenter={() => {
class="size-full"
onmouseenter={openLayerControl}
onmouseleave={closeLayerControl}
onpointerenter={() => {
if (!open) {
cancelEvents = true;
openLayerControl();
@@ -166,7 +175,7 @@
>
<div
class="flex flex-row justify-center items-center delay-100 transition-[opacity] duration-0 {open
? 'opacity-0 w-0 h-0 delay-0'
? 'opacity-0 size-0 delay-0'
: 'w-[29px] h-[29px]'}"
>
<Layers size="20" />
@@ -176,17 +185,21 @@
? 'grid-rows-[1fr] grid-cols-[1fr]'
: ''} {cancelEvents ? 'pointer-events-none' : ''}"
>
<ScrollArea>
<ScrollArea class="overflow-hidden">
<div class="h-fit">
<div class="p-2">
<div class="p-2 ml-1">
<LayerTree
layerTree={$selectedBasemapTree}
name="basemaps"
bind:selected={$selectedBasemap}
selected={$currentBasemap}
onselect={(value) => {
$previousBasemap = $currentBasemap;
$currentBasemap = value;
}}
/>
</div>
<Separator class="w-full" />
<div class="p-2">
<div class="p-2 ml-1">
{#if $currentOverlays}
<LayerTree
layerTree={$selectedOverlayTree}
@@ -197,7 +210,7 @@
{/if}
</div>
<Separator class="w-full" />
<div class="p-2">
<div class="p-2 ml-1">
{#if $currentOverpassQueries}
<LayerTree
layerTree={$selectedOverpassTree}
@@ -214,8 +227,9 @@
</CustomControl>
<svelte:window
on:click={(e) => {
if (open && !cancelEvents && !container.contains(e.target)) {
on:click={(e: MouseEvent) => {
const target = e.target as Node | null;
if (open && !cancelEvents && target && container && !container.contains(target)) {
closeLayerControl();
}
}}

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import LayerTree from './LayerTree.svelte';
import { Separator } from '$lib/components/ui/separator';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import * as Sheet from '$lib/components/ui/sheet';
@@ -8,7 +7,6 @@
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import { Slider } from '$lib/components/ui/slider';
import {
basemapTree,
defaultBasemap,
@@ -16,13 +14,13 @@
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';
import { writable } from 'svelte/store';
import { map } from '$lib/stores';
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n.svelte';
import { map } from '$lib/components/map/map';
import CustomLayers from './CustomLayers.svelte';
import { settings } from '$lib/logic/settings';
import { untrack } from 'svelte';
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
const {
selectedBasemapTree,
@@ -30,72 +28,99 @@
selectedOverpassTree,
currentBasemap,
currentOverlays,
currentOverpassQueries,
customLayers,
opacities,
} = settings;
export let open: boolean;
let accordionValue: string | string[] | undefined = undefined;
const { isLayerFromExtension, getLayerName } = extensionAPI;
let selectedOverlay = writable(undefined);
let overlayOpacity = writable([1]);
let { open = $bindable() }: { open: boolean } = $props();
let accordionValue: string | undefined = $state(undefined);
let selectedOverlay = $state(undefined);
let overlayOpacity = $state(1);
function setOpacityFromSelection() {
if ($selectedOverlay) {
let overlayId = $selectedOverlay.value;
if ($opacities.hasOwnProperty(overlayId)) {
$overlayOpacity = [$opacities[overlayId]];
if (selectedOverlay) {
if ($opacities.hasOwnProperty(selectedOverlay)) {
overlayOpacity = $opacities[selectedOverlay];
} else {
$overlayOpacity = [1];
overlayOpacity = 1;
}
} else {
$overlayOpacity = [1];
overlayOpacity = 1;
}
}
$: if ($selectedBasemapTree && $currentBasemap) {
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
$effect(() => {
if ($selectedBasemapTree && $currentBasemap) {
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
}
$currentBasemap = 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;
$effect(() => {
if ($selectedOverlayTree) {
untrack(() => {
if ($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();
}
$effect(() => {
if ($selectedOverpassTree) {
untrack(() => {
if ($currentOverpassQueries) {
let overlayLayers = getLayers($currentOverpassQueries);
let toRemove = Object.entries(overlayLayers).filter(
([id, checked]) => checked && !isSelected($selectedOverpassTree, id)
);
if (toRemove.length > 0) {
currentOverpassQueries.update((tree) => {
toRemove.forEach(([id]) => {
toggle(tree, id);
});
return tree;
});
}
}
});
}
});
</script>
<Sheet.Root bind:open>
<Sheet.Trigger class="hidden" />
<Sheet.Content>
<Sheet.Header class="h-full">
<Sheet.Title>{$_('layers.settings')}</Sheet.Title>
<Sheet.Title>{i18n._('layers.settings')}</Sheet.Title>
<ScrollArea class="w-[105%] min-h-full pr-4">
<Sheet.Description>
{$_('layers.settings_help')}
{i18n._('layers.settings_help')}
</Sheet.Description>
<Accordion.Root class="flex flex-col" bind:value={accordionValue}>
<Accordion.Root class="flex flex-col" bind:value={accordionValue} type="single">
<Accordion.Item value="layer-selection" class="flex flex-col">
<Accordion.Trigger>{$_('layers.selection')}</Accordion.Trigger>
<Accordion.Trigger>{i18n._('layers.selection')}</Accordion.Trigger>
<Accordion.Content class="grow flex flex-col border rounded">
<div class="py-2 pl-1 pr-2">
<div class="py-2 pl-3 pr-2">
<LayerTree
layerTree={basemapTree}
name="basemapSettings"
@@ -104,7 +129,7 @@
/>
</div>
<Separator />
<div class="py-2 pl-1 pr-2">
<div class="py-2 pl-3 pr-2">
<LayerTree
layerTree={overlayTree}
name="overlaySettings"
@@ -113,7 +138,7 @@
/>
</div>
<Separator />
<div class="py-2 pl-1 pr-2">
<div class="py-2 pl-3 pr-2">
<LayerTree
layerTree={overpassTree}
name="overpassSettings"
@@ -124,22 +149,40 @@
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="overlay-opacity">
<Accordion.Trigger>{$_('layers.opacity')}</Accordion.Trigger>
<Accordion.Trigger>{i18n._('layers.opacity')}</Accordion.Trigger>
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
<div class="flex flex-row gap-6 items-center">
<Label>
{$_('layers.custom_layers.overlay')}
{i18n._('layers.custom_layers.overlay')}
</Label>
<Select.Root bind:selected={$selectedOverlay}>
<Select.Trigger class="h-8 mr-1">
<Select.Value />
<Select.Root
bind:value={selectedOverlay}
type="single"
onValueChange={setOpacityFromSelection}
>
<Select.Trigger class="h-8 mr-1 w-full">
{#if selectedOverlay}
{#if isSelected($selectedOverlayTree, selectedOverlay)}
{#if $isLayerFromExtension(selectedOverlay)}
{$getLayerName(selectedOverlay)}
{:else}
{i18n._(`layers.label.${selectedOverlay}`)}
{/if}
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
{$customLayers[selectedOverlay].name}
{/if}
{/if}
</Select.Trigger>
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
{#each Object.keys(overlays) as id}
{#if isSelected($selectedOverlayTree, id)}
<Select.Item value={id}
>{$_(`layers.label.${id}`)}</Select.Item
>
<Select.Item value={id}>
{#if $isLayerFromExtension(id)}
{$getLayerName(id)}
{:else}
{i18n._(`layers.label.${id}`)}
{/if}
</Select.Item>
{/if}
{/each}
{#each Object.entries($customLayers) as [id, layer]}
@@ -151,30 +194,29 @@
</Select.Root>
</div>
<Label class="flex flex-row gap-6 items-center">
{$_('menu.style.opacity')}
{i18n._('menu.style.opacity')}
<div class="p-2 pr-3 grow">
<Slider
bind:value={$overlayOpacity}
bind:value={overlayOpacity}
type="single"
min={0.1}
max={1}
step={0.1}
disabled={$selectedOverlay === undefined}
disabled={selectedOverlay === undefined}
onValueChange={(value) => {
if ($selectedOverlay) {
if (selectedOverlay) {
if (
$map &&
isSelected(
$currentOverlays,
$selectedOverlay.value
)
$currentOverlays &&
isSelected($currentOverlays, selectedOverlay)
) {
try {
$map.removeImport($selectedOverlay.value);
$map.removeImport(selectedOverlay);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
$opacities[$selectedOverlay.value] = value[0];
$opacities[selectedOverlay] = value;
}
}}
/>
@@ -183,7 +225,8 @@
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="custom-layers">
<Accordion.Trigger>{$_('layers.custom_layers.title')}</Accordion.Trigger>
<Accordion.Trigger>{i18n._('layers.custom_layers.title')}</Accordion.Trigger
>
<Accordion.Content>
<ScrollArea>
<CustomLayers />

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import LayerTreeNode from './LayerTreeNode.svelte';
import { type LayerTreeType } from '$lib/assets/layers';
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
let {
layerTree,
name,
selected = '',
onselect = () => {},
multiple = false,
checked = $bindable({}),
}: {
layerTree: LayerTreeType;
name: string;
selected?: string;
onselect?: (value: string) => void;
multiple?: boolean;
checked?: LayerTreeType;
} = $props();
</script>
<form>
<fieldset class="min-w-64 mb-1">
<CollapsibleTree nohover={true}>
<LayerTreeNode {name} node={layerTree} {selected} {onselect} {multiple} bind:checked />
</CollapsibleTree>
</fieldset>
</form>

View File

@@ -1,25 +1,34 @@
<script lang="ts">
import Self from '$lib/components/map/layer-control/LayerTreeNode.svelte';
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
import CollapsibleTreeNode from '../collapsible-tree/CollapsibleTreeNode.svelte';
import CollapsibleTreeNode from '$lib/components/collapsible-tree/CollapsibleTreeNode.svelte';
import { type LayerTreeType } from '$lib/assets/layers';
import { anySelectedLayer } from './utils';
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings';
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
import { _ } from 'svelte-i18n';
import { settings } from '$lib/db';
import { beforeUpdate } from 'svelte';
export let name: string;
export let node: LayerTreeType;
export let selected: string | undefined = undefined;
export let multiple: boolean = false;
export let checked: LayerTreeType;
let {
name,
node,
selected = '',
onselect = () => {},
multiple = false,
checked = $bindable({}),
}: {
name: string;
node: LayerTreeType;
selected?: string;
onselect?: (value: string) => void;
multiple: boolean;
checked: LayerTreeType;
} = $props();
const { customLayers } = settings;
const { isLayerFromExtension, getLayerName } = extensionAPI;
beforeUpdate(() => {
$effect.pre(() => {
if (checked !== undefined) {
Object.keys(node).forEach((id) => {
if (!checked.hasOwnProperty(id)) {
@@ -46,7 +55,7 @@
value={id}
bind:checked={checked[id]}
class="scale-90"
aria-label={$_(`layers.label.${id}`)}
aria-label={i18n._(`layers.label.${id}`)}
/>
{:else}
<input
@@ -54,36 +63,50 @@
type="radio"
{name}
value={id}
bind:group={selected}
checked={selected === id}
oninput={(e) => {
if ((e.target as HTMLInputElement)?.checked) {
onselect(id);
}
}}
/>
{/if}
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
{#if $customLayers.hasOwnProperty(id)}
{$customLayers[id].name}
{:else if $isLayerFromExtension(id)}
{$getLayerName(id)}
{:else}
{$_(`layers.label.${id}`)}
{i18n._(`layers.label.${id}`)}
{/if}
</Label>
</div>
{/if}
{:else if anySelectedLayer(node[id])}
<CollapsibleTreeNode {id}>
<span slot="trigger">{$_(`layers.label.${id}`)}</span>
<div slot="content">
<svelte:self
node={node[id]}
{name}
bind:selected
{multiple}
bind:checked={checked[id]}
/>
</div>
{#snippet trigger()}
<span>{i18n._(`layers.label.${id}`)}</span>
{/snippet}
{#snippet content()}
<div class="ml-2">
<Self
node={node[id]}
{name}
{selected}
{onselect}
{multiple}
bind:checked={checked[id]}
/>
</div>
{/snippet}
</CollapsibleTreeNode>
{/if}
{/each}
</div>
<style lang="postcss">
@reference "../../../../app.css";
div :global(input[type='radio']) {
@apply appearance-none;
@apply w-4 h-4;

View File

@@ -1,26 +1,31 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { selection } from '$lib/components/file-list/Selection';
import { PencilLine, MapPin } from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import { dbUtils } from '$lib/db';
import type { PopupItem } from '$lib/components/MapPopup';
import { PencilLine, MapPin } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import type { WaypointType } from 'gpx';
import type { PopupItem } from '$lib/components/map/map-popup';
import { fileActions } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection';
export let poi: PopupItem<any>;
let {
poi,
}: {
poi: PopupItem<any>;
} = $props();
let tags: { [key: string]: string } = {};
let name = '';
$: if (poi) {
tags = JSON.parse(poi.item.tags);
if (tags.name !== undefined && tags.name !== '') {
name = tags.name;
} else {
name = $_(`layers.label.${poi.item.query}`);
let tags: Record<string, string> = $derived(poi ? JSON.parse(poi.item.tags) : {});
let name = $derived.by(() => {
if (poi) {
if (tags.name !== undefined && tags.name !== '') {
return tags.name;
} else {
return i18n._(`layers.label.${poi.item.query}`);
}
}
}
return '';
});
function addToFile() {
const desc = Object.entries(tags)
@@ -43,23 +48,24 @@
},
};
}
dbUtils.addOrUpdateWaypoint(wpt);
fileActions.addOrUpdateWaypoint(wpt);
}
</script>
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
<Card.Header class="p-0">
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0">
<Card.Header class="p-0 gap-0">
<Card.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">
<div class="text-muted-foreground text-xs font-normal">
{poi.item.lat.toFixed(6)}&deg; {poi.item.lon.toFixed(6)}&deg;
</div>
</div>
<Button
class="ml-auto p-1.5 h-8"
class="ml-auto"
variant="outline"
size="icon"
href="https://www.openstreetmap.org/edit?editor=id&{poi.item.type ??
'node'}={poi.item.id}"
target="_blank"
@@ -70,10 +76,10 @@
</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]">
<ScrollArea class="flex flex-col 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 -->
<!-- svelte-ignore a11y_missing_attribute -->
<img src={tags.image ?? tags['image:0']} />
</div>
{/if}
@@ -81,7 +87,7 @@
{#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'}
{#if key === 'website' || key.startsWith('website:') || key.endsWith(':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>
@@ -94,14 +100,9 @@
{/each}
</div>
</ScrollArea>
<Button
class="mt-2"
variant="outline"
disabled={$selection.size === 0}
on:click={addToFile}
>
<MapPin size="16" class="mr-1" />
{$_('toolbar.waypoint.add')}
<Button class="mt-2" variant="outline" disabled={$selection.size === 0} onclick={addToFile}>
<MapPin size="16" />
{i18n._('toolbar.waypoint.add')}
</Button>
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,171 @@
import { settings } from '$lib/logic/settings';
import { derived, get, writable, type Writable } from 'svelte/store';
import { isSelected, remove, removeAll } from './utils';
import { overlays, overlayTree } from '$lib/assets/layers';
import { browser } from '$app/environment';
import { map } from '$lib/components/map/map';
const { currentOverlays, previousOverlays, selectedOverlayTree } = settings;
export type CustomOverlay = {
id: string;
name: string;
tileUrls: string[];
maxZoom?: number;
};
export class ExtensionAPI {
private _overlays: Writable<Map<string, CustomOverlay>> = writable(new Map());
init() {
if (browser && !window.hasOwnProperty('gpxstudio')) {
Object.defineProperty(window, 'gpxstudio', {
value: this,
});
addEventListener('beforeunload', () => {
this.destroy();
});
}
}
ensureLoaded(): Promise<void> {
let unsubscribe: () => void;
const promise = new Promise<void>((resolve) => {
map.onLoad(() => {
unsubscribe = currentOverlays.subscribe((current) => {
if (current) {
resolve();
}
});
});
});
promise.finally(() => {
unsubscribe?.();
});
return promise;
}
addOrUpdateOverlay(overlay: CustomOverlay) {
if (!overlay.id || !overlay.name || !overlay.tileUrls || overlay.tileUrls.length === 0) {
throw new Error('Overlay must have an id, name, and at least one tile URL.');
}
overlay.id = this.getOverlayId(overlay.id);
this._overlays.update(($overlays) => {
$overlays.set(overlay.id, overlay);
return $overlays;
});
overlays[overlay.id] = {
version: 8,
sources: {
[overlay.id]: {
type: 'raster',
tiles: overlay.tileUrls,
tileSize: overlay.tileUrls.some((url) => url.includes('512')) ? 512 : 256,
maxzoom: overlay.maxZoom ?? 22,
},
},
layers: [
{
id: overlay.id,
type: 'raster',
source: overlay.id,
},
],
};
overlayTree.overlays.world[overlay.id] = true;
selectedOverlayTree.update((selected) => {
selected.overlays.world[overlay.id] = true;
return selected;
});
const current = get(currentOverlays);
let show = false;
if (current && isSelected(current, overlay.id)) {
show = true;
try {
get(map)?.removeImport(overlay.id);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
currentOverlays.update((current) => {
current.overlays.world[overlay.id] = show;
return current;
});
}
filterOverlays(ids: string[]) {
ids = ids.map((id) => this.getOverlayId(id));
const idsToRemove = Array.from(get(this._overlays).keys()).filter(
(id) => !ids.includes(id)
);
currentOverlays.update((current) => {
removeAll(current, idsToRemove);
return current;
});
previousOverlays.update((previous) => {
removeAll(previous, idsToRemove);
return previous;
});
selectedOverlayTree.update((selected) => {
removeAll(selected, idsToRemove);
return selected;
});
Object.keys(overlays).forEach((id) => {
if (idsToRemove.includes(id)) {
delete overlays[id];
}
});
removeAll(overlayTree, idsToRemove);
this._overlays.update(($overlays) => {
$overlays.forEach((_, id) => {
if (idsToRemove.includes(id)) {
$overlays.delete(id);
}
});
return $overlays;
});
}
isLayerFromExtension = derived(this._overlays, ($overlays) => {
return (id: string) => $overlays.has(id);
});
getLayerName = derived(this._overlays, ($overlays) => {
return (id: string) => $overlays.get(id)?.name || '';
});
private getOverlayId(id: string): string {
return `extension-${id}`;
}
private destroy() {
const ids = Array.from(get(this._overlays).keys());
currentOverlays.update((current) => {
ids.forEach((id) => {
remove(current, id);
});
return current;
});
previousOverlays.update((previous) => {
ids.forEach((id) => {
remove(previous, id);
});
return previous;
});
selectedOverlayTree.update((selected) => {
ids.forEach((id) => {
remove(selected, id);
});
return selected;
});
}
}
export const extensionAPI = new ExtensionAPI();

View File

@@ -1,10 +1,11 @@
import SphericalMercator from '@mapbox/sphericalmercator';
import { SphericalMercator } from '@mapbox/sphericalmercator';
import { getLayers } from './utils';
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';
import { MapPopup } from '$lib/components/map/map-popup';
import { settings } from '$lib/logic/settings';
import { db } from '$lib/db';
const { currentOverpassQueries } = settings;
@@ -60,8 +61,10 @@ export class OverpassLayer {
queryIfNeeded() {
if (this.map.getZoom() >= this.minZoom) {
const bounds = this.map.getBounds().toArray();
this.query([bounds[0][0], bounds[0][1], bounds[1][0], bounds[1][1]]);
const bounds = this.map.getBounds()?.toArray();
if (bounds) {
this.query([bounds[0][0], bounds[0][1], bounds[1][0], bounds[1][1]]);
}
}
}
@@ -71,7 +74,7 @@ export class OverpassLayer {
let d = get(data);
try {
let source = this.map.getSource('overpass');
let source = this.map.getSource('overpass') as mapboxgl.GeoJSONSource | undefined;
if (source) {
source.setData(d);
} else {
@@ -281,9 +284,9 @@ function getQuery(query: string) {
}
function getQueryItem(tags: Record<string, string | boolean | string[]>) {
let arrayEntry = Object.entries(tags).find(([_, value]) => Array.isArray(value));
let arrayEntry = Object.values(tags).find((value) => Array.isArray(value));
if (arrayEntry !== undefined) {
return arrayEntry[1]
return arrayEntry
.map(
(val) =>
`nwr${Object.entries(tags)

View File

@@ -55,4 +55,26 @@ export function toggle(node: LayerTreeType, id: string) {
return node;
}
export function remove(node: LayerTreeType, id: string) {
Object.keys(node).forEach((key) => {
if (key === id) {
delete node[key];
} else if (typeof node[key] !== 'boolean') {
remove(node[key], id);
}
});
return node;
}
export function removeAll(node: LayerTreeType, ids: string[]) {
Object.keys(node).forEach((key) => {
if (ids.includes(key)) {
delete node[key];
} else if (typeof node[key] !== 'boolean') {
removeAll(node[key], ids);
}
});
return node;
}
export const customBasemapUpdate = writable(0);

View File

@@ -1,8 +1,8 @@
import { TrackPoint, Waypoint } from 'gpx';
import mapboxgl from 'mapbox-gl';
import { tick } from 'svelte';
import { mount, tick, unmount } from 'svelte';
import { get, writable, type Writable } from 'svelte/store';
import MapPopupComponent from './MapPopup.svelte';
import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
export type PopupItem<T = Waypoint | TrackPoint | any> = {
item: T;
@@ -14,20 +14,21 @@ export class MapPopup {
map: mapboxgl.Map;
popup: mapboxgl.Popup;
item: Writable<PopupItem | null> = writable(null);
component: ReturnType<typeof mount>;
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({
this.component = mount(MapPopupComponent, {
target: document.body,
props: {
item: this.item,
onContainerReady: (container: HTMLDivElement) => {
this.popup.setDOMContent(container);
},
},
});
tick().then(() => this.popup.setDOMContent(component.container));
}
setItem(item: PopupItem | null) {
@@ -41,8 +42,8 @@ export class MapPopup {
}
show() {
const i = get(this.item);
if (i === null) {
const item = get(this.item);
if (item === null) {
this.hide();
return;
}
@@ -51,8 +52,8 @@ export class MapPopup {
}
maybeHide(e: mapboxgl.MapMouseEvent) {
const i = get(this.item);
if (i === null) {
const item = get(this.item);
if (item === null) {
this.hide();
return;
}
@@ -68,15 +69,16 @@ export class MapPopup {
remove() {
this.popup.remove();
unmount(this.component);
}
getCoordinates() {
const i = get(this.item);
if (i === null) {
const item = get(this.item);
if (item === 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);
return item.item instanceof Waypoint || item.item instanceof TrackPoint
? item.item.getCoordinates()
: new mapboxgl.LngLat(item.item.lon, item.item.lat);
}
}

View File

@@ -0,0 +1,227 @@
import mapboxgl from 'mapbox-gl';
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
import { get, writable, type Writable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
import { tick } from 'svelte';
const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings;
let fitBoundsOptions: mapboxgl.MapOptions['fitBoundsOptions'] = {
maxZoom: 15,
linear: true,
easing: () => 1,
};
export class MapboxGLMap {
private _map: Writable<mapboxgl.Map | null> = writable(null);
private _onLoadCallbacks: ((map: mapboxgl.Map) => void)[] = [];
private _unsubscribes: (() => void)[] = [];
subscribe(run: (value: mapboxgl.Map | null) => void, invalidate?: () => void) {
return this._map.subscribe(run, invalidate);
}
init(
accessToken: string,
language: string,
hash: boolean,
geocoder: boolean,
geolocate: boolean
) {
const map = new mapboxgl.Map({
container: 'map',
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=${accessToken}`,
},
},
{
id: 'basemap',
url: '',
},
{
id: 'overlays',
url: '',
data: {
version: 8,
sources: {},
layers: [],
},
},
],
},
projection: 'globe',
zoom: 0,
hash: hash,
language,
attributionControl: false,
logoPosition: 'bottom-right',
boxZoom: false,
});
map.addControl(
new mapboxgl.AttributionControl({
compact: true,
})
);
map.addControl(
new mapboxgl.NavigationControl({
visualizePitch: true,
})
);
if (geocoder) {
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();
}
};
map.addControl(geocoder);
}
if (geolocate) {
map.addControl(
new mapboxgl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true,
},
fitBoundsOptions,
trackUserLocation: true,
showUserHeading: true,
})
);
}
const scaleControl = new mapboxgl.ScaleControl({
unit: get(distanceUnits),
});
map.addControl(scaleControl);
map.on('style.load', () => {
map.addSource('mapbox-dem', {
type: 'raster-dem',
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
tileSize: 512,
maxzoom: 14,
});
if (map.getPitch() > 0) {
map.setTerrain({
source: 'mapbox-dem',
exaggeration: 1,
});
}
map.setFog({
color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)',
'horizon-blend': 0.1,
'space-color': 'rgb(156, 240, 255)',
});
map.on('pitch', () => {
if (map.getPitch() > 0) {
map.setTerrain({
source: 'mapbox-dem',
exaggeration: 1,
});
} else {
map.setTerrain(null);
}
});
});
map.on('load', () => {
this._map.set(map); // only set the store after the map has loaded
window._map = map; // entry point for extensions
this.resize();
scaleControl.setUnit(get(distanceUnits));
this._onLoadCallbacks.forEach((callback) => callback(map));
this._onLoadCallbacks = [];
});
this._unsubscribes.push(treeFileView.subscribe(() => this.resize()));
this._unsubscribes.push(elevationProfile.subscribe(() => this.resize()));
this._unsubscribes.push(bottomPanelSize.subscribe(() => this.resize()));
this._unsubscribes.push(rightPanelSize.subscribe(() => this.resize()));
this._unsubscribes.push(
distanceUnits.subscribe((units) => {
scaleControl.setUnit(units);
})
);
}
onLoad(callback: (map: mapboxgl.Map) => void) {
const map = get(this._map);
if (map) {
callback(map);
} else {
this._onLoadCallbacks.push(callback);
}
}
destroy() {
const map = get(this._map);
if (map) {
map.remove();
this._map.set(null);
}
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
this._unsubscribes = [];
}
resize() {
const map = get(this._map);
if (map) {
tick().then(() => {
map.resize();
});
}
}
toggle3D() {
const map = get(this._map);
if (map) {
if (map.getPitch() === 0) {
map.easeTo({ pitch: 70 });
} else {
map.easeTo({ pitch: 0 });
}
}
}
}
export const map = new MapboxGLMap();

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import { streetViewEnabled } from '$lib/components/map/street-view-control/utils';
import { map } from '$lib/components/map/map';
import CustomControl from '$lib/components/map/custom-control/CustomControl.svelte';
import { PersonStanding, X } from '@lucide/svelte';
import { MapillaryLayer } from './mapillary';
import { GoogleRedirect } from './google';
import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n.svelte';
import { onMount } from 'svelte';
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
const { streetViewSource } = settings;
let googleRedirect: GoogleRedirect | null = $state(null);
let mapillaryLayer: MapillaryLayer | null = $state(null);
let mapillaryOpen = $state({
value: false,
});
let container: HTMLElement;
onMount(() => {
map.onLoad((map: mapboxgl.Map) => {
googleRedirect = new GoogleRedirect(map);
mapillaryLayer = new MapillaryLayer(map, container, mapillaryOpen);
});
});
$effect(() => {
if ($streetViewSource === 'mapillary') {
googleRedirect?.remove();
if ($streetViewEnabled) {
mapillaryLayer?.add();
} else {
mapillaryLayer?.remove();
}
} else {
mapillaryLayer?.remove();
if ($streetViewEnabled) {
googleRedirect?.add();
} else {
googleRedirect?.remove();
}
}
});
</script>
<CustomControl class="w-[29px] h-[29px] shrink-0">
<ButtonWithTooltip
variant="ghost"
class="w-full h-full"
side="left"
label={i18n._('menu.toggle_street_view')}
onclick={() => {
$streetViewEnabled = !$streetViewEnabled;
}}
>
<PersonStanding
size="22"
class="size-5.5"
color={$streetViewEnabled ? '#33b5e5' : 'currentColor'}
/>
</ButtonWithTooltip>
</CustomControl>
<div
bind:this={container}
class="{mapillaryOpen.value
? ''
: '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 -->
<div
class="absolute top-0 right-0 z-10 bg-background p-1 rounded-bl-md cursor-pointer"
onclick={() => {
if (mapillaryLayer) {
mapillaryLayer.closePopup();
}
}}
>
<X size="16" />
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { resetCursor, setCrosshairCursor } from '$lib/utils';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import type mapboxgl from 'mapbox-gl';
export class GoogleRedirect {
@@ -13,7 +13,7 @@ export class GoogleRedirect {
if (this.enabled) return;
this.enabled = true;
setCrosshairCursor();
mapCursor.notify(MapCursorState.STREET_VIEW_CROSSHAIR, true);
this.map.on('click', this.openStreetView);
}
@@ -21,11 +21,11 @@ export class GoogleRedirect {
if (!this.enabled) return;
this.enabled = false;
resetCursor();
mapCursor.notify(MapCursorState.STREET_VIEW_CROSSHAIR, false);
this.map.off('click', this.openStreetView);
}
openStreetView(e) {
openStreetView(e: mapboxgl.MapMouseEvent) {
window.open(
`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${e.lngLat.lat},${e.lngLat.lng}`
);

View File

@@ -1,8 +1,7 @@
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';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
const mapillarySource: VectorSourceSpecification = {
type: 'vector',
@@ -47,13 +46,13 @@ export class MapillaryLayer {
viewer: Viewer;
active = false;
popupOpen: Writable<boolean>;
popupOpen: { value: boolean };
addBinded = this.add.bind(this);
onMouseEnterBinded = this.onMouseEnter.bind(this);
onMouseLeaveBinded = this.onMouseLeave.bind(this);
constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: Writable<boolean>) {
constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: { value: boolean }) {
this.map = map;
this.viewer = new Viewer({
@@ -77,7 +76,7 @@ export class MapillaryLayer {
this.viewer.on('position', async () => {
if (this.active) {
popupOpen.set(true);
popupOpen.value = true;
let latLng = await this.viewer.getPosition();
this.marker.setLngLat(latLng).addTo(this.map);
if (!this.map.getBounds()?.contains(latLng)) {
@@ -126,25 +125,32 @@ export class MapillaryLayer {
}
this.marker.remove();
this.popupOpen.set(false);
this.popupOpen.value = false;
}
closePopup() {
this.active = false;
this.marker.remove();
this.popupOpen.set(false);
this.popupOpen.value = false;
}
onMouseEnter(e: mapboxgl.MapMouseEvent) {
this.active = true;
if (
e.features &&
e.features.length > 0 &&
e.features[0].properties &&
e.features[0].properties.id
) {
this.active = true;
this.viewer.resize();
this.viewer.moveTo(e.features[0].properties.id);
this.viewer.resize();
this.viewer.moveTo(e.features[0].properties.id);
setPointerCursor();
mapCursor.notify(MapCursorState.MAPILLARY_HOVER, true);
}
}
onMouseLeave() {
resetCursor();
mapCursor.notify(MapCursorState.MAPILLARY_HOVER, false);
}
}

View File

@@ -0,0 +1,3 @@
import { writable } from 'svelte/store';
export const streetViewEnabled = writable(false);

View File

@@ -1,74 +0,0 @@
<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, mapillaryOpen);
}
$: if (mapillaryLayer) {
if ($streetViewSource === 'mapillary') {
googleRedirect.remove();
if ($streetViewEnabled) {
mapillaryLayer.add();
} else {
mapillaryLayer.remove();
}
} else {
mapillaryLayer.remove();
if ($streetViewEnabled) {
googleRedirect.add();
} else {
googleRedirect.remove();
}
}
}
</script>
<CustomControl class="w-[29px] h-[29px] shrink-0">
<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="{$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 -->
<div
class="absolute top-0 right-0 z-10 bg-background p-1 rounded-bl-md cursor-pointer"
on:click={() => {
if (mapillaryLayer) {
mapillaryLayer.closePopup();
}
}}
>
<X size="16" />
</div>
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { Tool } from '$lib/stores';
import ToolbarItem from './ToolbarItem.svelte';
import ToolbarItem from '$lib/components/toolbar/ToolbarItem.svelte';
import ToolbarItemMenu from '$lib/components/toolbar/ToolbarItemMenu.svelte';
import { Tool } from '$lib/components/toolbar/tools';
import {
Group,
CalendarClock,
@@ -8,47 +9,49 @@
SquareDashedMousePointer,
Ungroup,
MapPin,
Filter,
Funnel,
Scissors,
MountainSnow,
} from 'lucide-svelte';
} from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { _ } from 'svelte-i18n';
import ToolbarItemMenu from './ToolbarItemMenu.svelte';
let props: {
class?: string;
} = $props();
</script>
<div class="flex flex-row w-full items-center pr-12">
<div
class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {$$props.class ??
class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {props.class ??
''}"
>
<ToolbarItem tool={Tool.ROUTING} label={$_('toolbar.routing.tooltip')}>
<Pencil slot="icon" size="18" />
<ToolbarItem itemTool={Tool.ROUTING} label={i18n._('toolbar.routing.tooltip')}>
<Pencil size="18" class="size-4.5" />
</ToolbarItem>
<ToolbarItem tool={Tool.WAYPOINT} label={$_('toolbar.waypoint.tooltip')}>
<MapPin slot="icon" size="18" />
<ToolbarItem itemTool={Tool.WAYPOINT} label={i18n._('toolbar.waypoint.tooltip')}>
<MapPin size="18" class="size-4.5" />
</ToolbarItem>
<ToolbarItem tool={Tool.SCISSORS} label={$_('toolbar.scissors.tooltip')}>
<Scissors slot="icon" size="18" />
<ToolbarItem itemTool={Tool.SCISSORS} label={i18n._('toolbar.scissors.tooltip')}>
<Scissors size="18" class="size-4.5" />
</ToolbarItem>
<ToolbarItem tool={Tool.TIME} label={$_('toolbar.time.tooltip')}>
<CalendarClock slot="icon" size="18" />
<ToolbarItem itemTool={Tool.TIME} label={i18n._('toolbar.time.tooltip')}>
<CalendarClock size="18" class="size-4.5" />
</ToolbarItem>
<ToolbarItem tool={Tool.MERGE} label={$_('toolbar.merge.tooltip')}>
<Group slot="icon" size="18" />
<ToolbarItem itemTool={Tool.MERGE} label={i18n._('toolbar.merge.tooltip')}>
<Group size="18" class="size-4.5" />
</ToolbarItem>
<ToolbarItem tool={Tool.EXTRACT} label={$_('toolbar.extract.tooltip')}>
<Ungroup slot="icon" size="18" />
<ToolbarItem itemTool={Tool.EXTRACT} label={i18n._('toolbar.extract.tooltip')}>
<Ungroup size="18" class="size-4.5" />
</ToolbarItem>
<ToolbarItem tool={Tool.ELEVATION} label={$_('toolbar.elevation.button')}>
<MountainSnow slot="icon" size="18" />
<ToolbarItem itemTool={Tool.ELEVATION} label={i18n._('toolbar.elevation.button')}>
<MountainSnow size="18" class="size-4.5" />
</ToolbarItem>
<ToolbarItem tool={Tool.REDUCE} label={$_('toolbar.reduce.tooltip')}>
<Filter slot="icon" size="18" />
<ToolbarItem itemTool={Tool.REDUCE} label={i18n._('toolbar.reduce.tooltip')}>
<Funnel size="18" class="size-4.5" />
</ToolbarItem>
<ToolbarItem tool={Tool.CLEAN} label={$_('toolbar.clean.tooltip')}>
<SquareDashedMousePointer slot="icon" size="18" />
<ToolbarItem itemTool={Tool.CLEAN} label={i18n._('toolbar.clean.tooltip')}>
<SquareDashedMousePointer size="18" class="size-4.5" />
</ToolbarItem>
</div>
<ToolbarItemMenu class={$$props.class ?? ''} />
<ToolbarItemMenu class={props.class ?? ''} />
</div>

View File

@@ -1,29 +1,45 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { currentTool, type Tool } from '$lib/stores';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import type { Snippet } from 'svelte';
export let tool: Tool;
export let label: string;
let {
itemTool,
label,
children,
}: {
itemTool: Tool;
label: string;
children: Snippet;
} = $props();
function toggleTool() {
currentTool.update((current) => (current === tool ? null : tool));
if ($currentTool === itemTool) {
$currentTool = null;
} else {
$currentTool = itemTool;
}
}
</script>
<Tooltip.Root openDelay={300}>
<Tooltip.Trigger asChild let:builder>
<Button
builders={[builder]}
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">
<span>{label}</span>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
class="size-[24px] {$currentTool === itemTool ? 'bg-accent' : ''}"
onclick={toggleTool}
aria-label={label}
>
{@render children()}
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
<span>{label}</span>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>

View File

@@ -1,44 +1,49 @@
<script lang="ts">
import { Tool, currentTool } from '$lib/stores';
import { settings } from '$lib/db';
import { flyAndScale } from '$lib/utils';
import { Tool, currentTool } from '$lib/components/toolbar/tools';
import * as Card from '$lib/components/ui/card';
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
import Waypoint from '$lib/components/toolbar/tools/waypoint/Waypoint.svelte';
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 Extract from '$lib/components/toolbar/tools/Extract.svelte';
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
import Reduce from '$lib/components/toolbar/tools/Reduce.svelte';
import Reduce from '$lib/components/toolbar/tools/reduce/Reduce.svelte';
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
import { onMount } from 'svelte';
import mapboxgl from 'mapbox-gl';
import { settings } from '$lib/logic/settings';
let {
class: className = '',
}: {
class: string;
} = $props();
const { minimizeRoutingMenu } = settings;
let popupElement: HTMLElement;
let popup: mapboxgl.Popup;
onMount(() => {
popup = new mapboxgl.Popup({
let popupElement: HTMLDivElement | undefined = $state(undefined);
let popup: mapboxgl.Popup | undefined = $derived.by(() => {
if (!popupElement) {
return undefined;
}
let popup = new mapboxgl.Popup({
closeButton: false,
maxWidth: undefined,
});
popup.setDOMContent(popupElement);
popupElement.classList.remove('hidden');
return popup;
});
</script>
{#if $currentTool !== null}
<div
in:flyAndScale={{ x: -2, y: 0, duration: 100 }}
class="translate-x-1 h-full {$$props.class ?? ''}"
class="translate-x-1 h-full animate-in fade-in-0 zoom-in-95 slide-in-from-left-2 {className}"
>
<div class="rounded-md shadow-md pointer-events-auto">
<Card.Root class="rounded-md border-none">
<Card.Content class="p-2.5">
<Card.Root class="rounded-md border-none py-2.5">
<Card.Content class="px-2.5">
{#if $currentTool === Tool.ROUTING}
<Routing {popup} {popupElement} bind:minimized={$minimizeRoutingMenu} />
{:else if $currentTool === Tool.SCISSORS}
@@ -67,7 +72,7 @@
<svelte:window
on:keydown={(e) => {
if ($currentTool !== null && e.key === 'Escape') {
currentTool.set(null);
$currentTool = null;
}
}}
/>

View File

@@ -0,0 +1,15 @@
import { writable, type Writable } from 'svelte/store';
export enum Tool {
ROUTING,
WAYPOINT,
SCISSORS,
TIME,
MERGE,
EXTRACT,
ELEVATION,
REDUCE,
CLEAN,
}
export const currentTool: Writable<Tool | null> = writable(null);

View File

@@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
enum CleanType {
INSIDE = 'inside',
OUTSIDE = 'outside',
@@ -11,27 +11,33 @@
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Button } from '$lib/components/ui/button';
import Help from '$lib/components/Help.svelte';
import { _, locale } from 'svelte-i18n';
import { i18n } from '$lib/i18n.svelte';
import { onDestroy, onMount } from 'svelte';
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';
import { dbUtils } from '$lib/db';
import { getURLForLanguage } from '$lib/utils';
import { Trash2 } from '@lucide/svelte';
import { map } from '$lib/components/map/map';
import type { GeoJSONSource } from 'mapbox-gl';
import { selection } from '$lib/logic/selection';
import { fileActions } from '$lib/logic/file-actions';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
let cleanType = CleanType.INSIDE;
let deleteTrackpoints = true;
let deleteWaypoints = true;
let rectangleCoordinates: mapboxgl.LngLat[] = [];
let props: {
class?: string;
} = $props();
function updateRectangle() {
let cleanType = $state(CleanType.INSIDE);
let deleteTrackpoints = $state(true);
let deleteWaypoints = $state(true);
let rectangleCoordinates: mapboxgl.LngLat[] = $state([]);
$effect(() => {
if ($map) {
if (rectangleCoordinates.length != 2) {
if ($map.getLayer('rectangle')) {
$map.removeLayer('rectangle');
}
} else {
let data = {
let data: GeoJSON.Feature = {
type: 'Feature',
geometry: {
type: 'Polygon',
@@ -45,8 +51,9 @@
],
],
},
properties: {},
};
let source = $map.getSource('rectangle');
let source: GeoJSONSource | undefined = $map.getSource('rectangle');
if (source) {
source.setData(data);
} else {
@@ -68,11 +75,7 @@
}
}
}
}
$: if (rectangleCoordinates) {
updateRectangle();
}
});
let mousedown = false;
function onMouseDown(e: any) {
@@ -91,22 +94,21 @@
}
onMount(() => {
setCrosshairCursor();
if ($map) {
mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, true);
$map.on('mousedown', onMouseDown);
$map.on('mousemove', onMouseMove);
$map.on('mouseup', onMouseUp);
$map.on('touchstart', onMouseDown);
$map.on('touchmove', onMouseMove);
$map.on('touchend', onMouseUp);
$map.dragPan.disable();
}
});
$: if ($map) {
$map.on('mousedown', onMouseDown);
$map.on('mousemove', onMouseMove);
$map.on('mouseup', onMouseUp);
$map.on('touchstart', onMouseDown);
$map.on('touchmove', onMouseMove);
$map.on('touchend', onMouseUp);
$map.dragPan.disable();
}
onDestroy(() => {
resetCursor();
if ($map) {
mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, false);
$map.off('mousedown', onMouseDown);
$map.off('mousemove', onMouseMove);
$map.off('mouseup', onMouseUp);
@@ -124,31 +126,31 @@
}
});
$: validSelection = $selection.size > 0;
let validSelection = $derived($selection.size > 0);
</script>
<div class="flex flex-col gap-3 w-full max-w-80 items-center {$$props.class ?? ''}">
<div class="flex flex-col gap-3 w-full max-w-80 items-center {props.class ?? ''}">
<fieldset class="flex flex-col gap-3">
<div class="flex flex-row items-center gap-[6.4px] h-3">
<Checkbox id="delete-trkpt" bind:checked={deleteTrackpoints} class="scale-90" />
<Label for="delete-trkpt">
{$_('toolbar.clean.delete_trackpoints')}
{i18n._('toolbar.clean.delete_trackpoints')}
</Label>
</div>
<div class="flex flex-row items-center gap-[6.4px] h-3">
<Checkbox id="delete-wpt" bind:checked={deleteWaypoints} class="scale-90" />
<Label for="delete-wpt">
{$_('toolbar.clean.delete_waypoints')}
{i18n._('toolbar.clean.delete_waypoints')}
</Label>
</div>
<RadioGroup.Root bind:value={cleanType}>
<Label class="flex flex-row items-center gap-2">
<RadioGroup.Item value={CleanType.INSIDE} />
{$_('toolbar.clean.delete_inside')}
{i18n._('toolbar.clean.delete_inside')}
</Label>
<Label class="flex flex-row items-center gap-2">
<RadioGroup.Item value={CleanType.OUTSIDE} />
{$_('toolbar.clean.delete_outside')}
{i18n._('toolbar.clean.delete_outside')}
</Label>
</RadioGroup.Root>
</fieldset>
@@ -156,8 +158,8 @@
variant="outline"
class="w-full"
disabled={!validSelection || rectangleCoordinates.length != 2}
on:click={() => {
dbUtils.cleanSelection(
onclick={() => {
fileActions.cleanSelection(
[
{
lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
@@ -175,14 +177,14 @@
rectangleCoordinates = [];
}}
>
<Trash2 size="16" class="mr-1" />
{$_('toolbar.clean.button')}
<Trash2 size="16" />
{i18n._('toolbar.clean.button')}
</Button>
<Help link={getURLForLanguage($locale, '/help/toolbar/clean')}>
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/clean')}>
{#if validSelection}
{$_('toolbar.clean.help')}
{i18n._('toolbar.clean.help')}
{:else}
{$_('toolbar.clean.help_no_selection')}
{i18n._('toolbar.clean.help_no_selection')}
{/if}
</Help>
</div>

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