1505 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
635 changed files with 11169 additions and 12643 deletions
+1 -1
View File
@@ -1 +1 @@
open_collective: gpxstudio
ko_fi: gpxstudio
+48 -48
View File
@@ -1,63 +1,63 @@
name: Deploy to GitHub Pages
on:
push:
branches: 'main'
push:
branches: 'main'
jobs:
build_site:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
build_site:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: |
gpx/package-lock.json
website/package-lock.json
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: |
gpx/package-lock.json
website/package-lock.json
- name: Install dependencies for gpx
run: npm install --prefix gpx
- name: Install dependencies for gpx
run: npm install --prefix gpx
- name: Build gpx
run: npm run build --prefix gpx
- name: Build gpx
run: npm run build --prefix gpx
- name: Install dependencies for website
run: npm install --prefix website
- name: Install dependencies for website
run: npm install --prefix website
- name: Create env file
run: |
touch website/.env
echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env
cat website/.env
- name: Create env file
run: |
touch website/.env
echo PUBLIC_MAPBOX_TOKEN=${{ secrets.PUBLIC_MAPBOX_TOKEN }} >> website/.env
cat website/.env
- name: Build website
env:
BASE_PATH: ''
run: |
npm run build --prefix website
- name: Build website
env:
BASE_PATH: ''
run: |
npm run build --prefix website
- name: Upload Artifacts
uses: actions/upload-pages-artifact@v4
with:
path: 'website/build/'
- name: Upload Artifacts
uses: actions/upload-pages-artifact@v3
with:
path: 'website/build/'
deploy:
needs: build_site
runs-on: ubuntu-latest
deploy:
needs: build_site
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
+6 -3
View File
@@ -1,3 +1,6 @@
website/src/lib/components/ui
website/src/lib/docs/**/*.mdx
**/*.webmanifest
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
src/lib/components/ui
*.mdx
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 gpx.studio
Copyright (c) 2024 gpx.studio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+24 -24
View File
@@ -5,7 +5,7 @@
[**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files.
![gpx.studio screenshot](website/src/lib/assets/img/docs/getting-started/interface.webp)
![gpx.studio screenshot](website/src/lib/assets/img/docs/getting-started/interface.png)
This repository contains the source code of the website.
@@ -27,8 +27,8 @@ Any help is greatly appreciated!
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.
@@ -42,11 +42,11 @@ npm run build
### Running the website
To be able to load the map, you will need to create your own <a href="https://cloud.maptiler.com/auth/widget?next=https://cloud.maptiler.com/maps/" target="_blank">MapTiler key</a> and store it in a `.env` file in the `website` directory.
To be able to load the map, you will need to create your own <a href="https://account.mapbox.com/auth/signup" target="_blank">Mapbox access token</a> and store it in a `.env` file in the `website` directory.
```bash
cd website
echo PUBLIC_MAPTILER_KEY={YOUR_MAPTILER_KEY} >> .env
echo PUBLIC_MAPBOX_TOKEN={YOUR_MAPBOX_TOKEN} >> .env
npm install
npm run dev
```
@@ -55,25 +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
- 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:
- [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive map rendering
- [GraphHopper](https://github.com/graphhopper/graphhopper) — routing engine
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by most of the map layers, and by the routing engine
- 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
+2 -2
View File
@@ -25,7 +25,7 @@
"scripts": {
"build": "tsc",
"postinstall": "npm run build",
"lint": "prettier --check . --config ../.prettierrc && eslint .",
"format": "prettier --write . --config ../.prettierrc"
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
}
}
+466 -220
View File
@@ -1,5 +1,4 @@
import { ramerDouglasPeucker } from './simplify';
import { GPXStatistics, GPXStatisticsGroup, TrackPointLocalStatistics } from './statistics';
import {
Coordinates,
GPXFileAttributes,
@@ -18,9 +17,6 @@ import {
import { immerable, isDraft, original, freeze } from 'immer';
function cloneJSON<T>(obj: T): T {
if (obj === undefined) {
return undefined;
}
if (obj === null || typeof obj !== 'object') {
return null;
}
@@ -37,6 +33,7 @@ export abstract class GPXTreeElement<T extends GPXTreeElement<any>> {
abstract getNumberOfTrackPoints(): number;
abstract getStartTimestamp(): Date | undefined;
abstract getEndTimestamp(): Date | undefined;
abstract getStatistics(): GPXStatistics;
abstract getSegments(): TrackSegment[];
abstract getTrackPoints(): TrackPoint[];
@@ -76,6 +73,14 @@ abstract class GPXTreeNode<T extends GPXTreeElement<any>> extends GPXTreeElement
return this.children[this.children.length - 1].getEndTimestamp();
}
getStatistics(): GPXStatistics {
let statistics = new GPXStatistics();
for (let child of this.children) {
statistics.mergeWith(child.getStatistics());
}
return statistics;
}
getSegments(): TrackSegment[] {
return this.children.flatMap((child) => child.getSegments());
}
@@ -140,9 +145,7 @@ export class GPXFile extends GPXTreeNode<Track> {
},
},
};
this.wpt = gpx.wpt
? gpx.wpt.map((waypoint, index) => new Waypoint(waypoint, index))
: [];
this.wpt = gpx.wpt ? gpx.wpt.map((waypoint) => new Waypoint(waypoint)) : [];
this.trk = gpx.trk ? gpx.trk.map((track) => new Track(track)) : [];
if (gpx.rte && gpx.rte.length > 0) {
this.trk = this.trk.concat(gpx.rte.map((route) => convertRouteToTrack(route)));
@@ -180,6 +183,9 @@ export class GPXFile extends GPXTreeNode<Track> {
segment._data['segmentIndex'] = segmentIndex;
});
});
this.wpt.forEach((waypoint, waypointIndex) => {
waypoint._data['index'] = waypointIndex;
});
}
get children(): Array<Track> {
@@ -200,16 +206,8 @@ export class GPXFile extends GPXTreeNode<Track> {
});
}
getStatistics(): GPXStatisticsGroup {
let statistics = new GPXStatisticsGroup();
this.forEachSegment((segment) => {
statistics.add(segment.getStatistics());
});
return statistics;
}
getStyle(defaultColor?: string): MergedLineStyles {
const style = this.trk
return this.trk
.map((track) => track.getStyle())
.reduce(
(acc, style) => {
@@ -219,6 +217,8 @@ export class GPXFile extends GPXTreeNode<Track> {
!acc.color.includes(style['gpx_style:color'])
) {
acc.color.push(style['gpx_style:color']);
} else if (defaultColor && !acc.color.includes(defaultColor)) {
acc.color.push(defaultColor);
}
if (
style &&
@@ -242,10 +242,6 @@ export class GPXFile extends GPXTreeNode<Track> {
width: [],
}
);
if (style.color.length === 0 && defaultColor) {
style.color.push(defaultColor);
}
return style;
}
clone(): GPXFile {
@@ -808,7 +804,7 @@ export class TrackSegment extends GPXTreeLeaf {
constructor(segment?: (TrackSegmentType & { _data?: any }) | TrackSegment) {
super();
if (segment) {
this.trkpt = segment.trkpt.map((point, index) => new TrackPoint(point, index));
this.trkpt = segment.trkpt.map((point) => new TrackPoint(point));
if (segment.hasOwnProperty('_data')) {
this._data = segment._data;
}
@@ -820,12 +816,15 @@ export class TrackSegment extends GPXTreeLeaf {
_computeStatistics(): GPXStatistics {
let statistics = new GPXStatistics();
statistics.global.length = this.trkpt.length;
statistics.local.points = this.trkpt.slice(0);
statistics.local.data = this.trkpt.map(() => new TrackPointLocalStatistics());
statistics.local.points = this.trkpt.map((point) => point);
statistics.local.elevation.smoothed = this._computeSmoothedElevation();
statistics.local.slope.at = this._computeSlope();
const points = this.trkpt;
for (let i = 0; i < points.length; i++) {
points[i]._data['index'] = i;
// distance
let dist = 0;
if (i > 0) {
@@ -834,18 +833,34 @@ export class TrackSegment extends GPXTreeLeaf {
statistics.global.distance.total += dist;
}
statistics.local.data[i].distance.total = statistics.global.distance.total;
statistics.local.distance.total.push(statistics.global.distance.total);
// elevation
if (i > 0) {
const ele =
statistics.local.elevation.smoothed[i] -
statistics.local.elevation.smoothed[i - 1];
if (ele > 0) {
statistics.global.elevation.gain += ele;
} else if (ele < 0) {
statistics.global.elevation.loss -= ele;
}
}
statistics.local.elevation.gain.push(statistics.global.elevation.gain);
statistics.local.elevation.loss.push(statistics.global.elevation.loss);
// time
if (points[i].time === undefined) {
statistics.local.data[i].time.total = 0;
statistics.local.time.total.push(0);
} else {
if (statistics.global.time.start === undefined) {
statistics.global.time.start = points[i].time;
}
statistics.global.time.end = points[i].time;
statistics.local.data[i].time.total =
(points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000;
statistics.local.time.total.push(
(points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000
);
}
// speed
@@ -860,8 +875,8 @@ export class TrackSegment extends GPXTreeLeaf {
}
}
statistics.local.data[i].distance.moving = statistics.global.distance.moving;
statistics.local.data[i].time.moving = statistics.global.time.moving;
statistics.local.distance.moving.push(statistics.global.distance.moving);
statistics.local.time.moving.push(statistics.global.time.moving);
// bounds
statistics.global.bounds.southWest.lat = Math.min(
@@ -945,7 +960,8 @@ export class TrackSegment extends GPXTreeLeaf {
}
}
this._elevationComputation(statistics);
[statistics.local.slope.segment, statistics.local.slope.length] =
this._computeSlopeSegments(statistics);
statistics.global.time.total =
statistics.global.time.start && statistics.global.time.end
@@ -961,115 +977,73 @@ export class TrackSegment extends GPXTreeLeaf {
? statistics.global.distance.moving / (statistics.global.time.moving / 3600)
: 0;
timeWindowSmoothing(
statistics.local.speed = distanceWindowSmoothingWithDistanceAccumulator(
points,
10000,
(start, end) =>
200,
(accumulated, start, end) =>
points[start].time && points[end].time
? (3600 *
(statistics.local.data[end].distance.total -
statistics.local.data[start].distance.total)) /
Math.max(
(points[end].time.getTime() - points[start].time.getTime()) / 1000,
1
)
: undefined,
(value, index) => {
statistics.local.data[index].speed = value;
}
? (3600 * accumulated) /
(points[end].time.getTime() - points[start].time.getTime())
: undefined
);
return statistics;
}
_elevationComputation(statistics: GPXStatistics) {
_computeSmoothedElevation(): number[] {
const points = this.trkpt;
let smoothed = distanceWindowSmoothing(
points,
100,
(index) => points[index].ele ?? 0,
(accumulated, start, end) => accumulated / (end - start + 1)
);
if (points.length > 0) {
smoothed[0] = points[0].ele ?? 0;
smoothed[points.length - 1] = points[points.length - 1].ele ?? 0;
}
return smoothed;
}
_computeSlope(): number[] {
const points = this.trkpt;
return distanceWindowSmoothingWithDistanceAccumulator(
points,
50,
(accumulated, start, end) =>
(100 * ((points[end].ele ?? 0) - (points[start].ele ?? 0))) /
(accumulated > 0 ? accumulated : 1)
);
}
_computeSlopeSegments(statistics: GPXStatistics): [number[], number[]] {
let simplified = ramerDouglasPeucker(
this.trkpt,
20,
getElevationDistanceFunction(statistics)
);
for (let i = 0; i < simplified.length - 1; i++) {
let start = simplified[i].point._data.index;
let end = simplified[i + 1].point._data.index;
let cumulEle = 0;
let currentStart = start;
let currentEnd = start;
let prevSmoothedEle = 0;
distanceWindowSmoothing(
start,
end + 1,
statistics,
0.1,
(s, e) => {
for (let i = currentStart; i < s; i++) {
cumulEle -= this.trkpt[i].ele ?? 0;
}
for (let i = currentEnd; i <= e; i++) {
cumulEle += this.trkpt[i].ele ?? 0;
}
currentStart = s;
currentEnd = e + 1;
return cumulEle / (e - s + 1);
},
(smoothedEle, j) => {
if (j === start) {
smoothedEle = this.trkpt[start].ele ?? 0;
prevSmoothedEle = smoothedEle;
} else if (j === end) {
smoothedEle = this.trkpt[end].ele ?? 0;
}
const ele = smoothedEle - prevSmoothedEle;
if (ele > 0) {
statistics.global.elevation.gain += ele;
} else if (ele < 0) {
statistics.global.elevation.loss -= ele;
}
prevSmoothedEle = smoothedEle;
if (j < end) {
statistics.local.data[j].elevation.gain = statistics.global.elevation.gain;
statistics.local.data[j].elevation.loss = statistics.global.elevation.loss;
}
}
);
}
if (statistics.global.length > 0) {
statistics.local.data[statistics.global.length - 1].elevation.gain =
statistics.global.elevation.gain;
statistics.local.data[statistics.global.length - 1].elevation.loss =
statistics.global.elevation.loss;
}
let slope = [];
let length = [];
for (let i = 0; i < simplified.length - 1; i++) {
let start = simplified[i].point._data.index;
let end = simplified[i + 1].point._data.index;
let dist =
statistics.local.data[end].distance.total -
statistics.local.data[start].distance.total;
statistics.local.distance.total[end] - statistics.local.distance.total[start];
let ele = (simplified[i + 1].point.ele ?? 0) - (simplified[i].point.ele ?? 0);
for (let j = start; j < end + (i + 1 === simplified.length - 1 ? 1 : 0); j++) {
statistics.local.data[j].slope.segment = (0.1 * ele) / dist;
statistics.local.data[j].slope.length = dist;
slope.push((0.1 * ele) / dist);
length.push(dist);
}
}
distanceWindowSmoothing(
0,
this.trkpt.length,
statistics,
0.05,
(start, end) => {
const ele = this.trkpt[end].ele - this.trkpt[start].ele || 0;
const dist =
statistics.local.data[end].distance.total -
statistics.local.data[start].distance.total;
return dist > 0 ? (0.1 * ele) / dist : 0;
},
(value, index) => {
statistics.local.data[index].slope.at = value;
}
);
return [slope, length];
}
getNumberOfTrackPoints(): number {
@@ -1316,8 +1290,8 @@ export class TrackSegment extends GPXTreeLeaf {
lastPoint: TrackPoint | undefined
) {
let og = getOriginal(this); // Read as much as possible from the original object because it is faster
let statistics = og._computeStatistics();
let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, statistics);
let slope = og._computeSlope();
let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, slope);
this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
}
@@ -1326,7 +1300,6 @@ export class TrackSegment extends GPXTreeLeaf {
}
}
const emptyExtensions: Record<string, string> = {};
export class TrackPoint {
[immerable] = true;
@@ -1337,7 +1310,7 @@ export class TrackPoint {
_data: { [key: string]: any } = {};
constructor(point: (TrackPointType & { _data?: any }) | TrackPoint, index?: number) {
constructor(point: (TrackPointType & { _data?: any }) | TrackPoint) {
this.attributes = point.attributes;
this.ele = point.ele;
this.time = point.time;
@@ -1345,9 +1318,6 @@ export class TrackPoint {
if (point.hasOwnProperty('_data')) {
this._data = point._data;
}
if (index !== undefined) {
this._data.index = index;
}
}
getCoordinates(): Coordinates {
@@ -1398,7 +1368,10 @@ export class TrackPoint {
: undefined;
}
setExtension(key: string, value: string) {
setExtensions(extensions: Record<string, string>) {
if (Object.keys(extensions).length === 0) {
return;
}
if (!this.extensions) {
this.extensions = {};
}
@@ -1408,12 +1381,8 @@ export class TrackPoint {
if (!this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']) {
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] = {};
}
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value;
}
setExtensions(extensions: Record<string, string>) {
Object.entries(extensions).forEach(([key, value]) => {
this.setExtension(key, value);
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value;
});
}
@@ -1422,7 +1391,7 @@ export class TrackPoint {
this.extensions['gpxtpx:TrackPointExtension'] &&
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']
? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']
: emptyExtensions;
: {};
}
toTrackPointType(exclude: string[] = []): TrackPointType {
@@ -1492,18 +1461,11 @@ export class TrackPoint {
clone(): TrackPoint {
return new TrackPoint({
attributes: {
lat: this.attributes.lat,
lon: this.attributes.lon,
},
attributes: cloneJSON(this.attributes),
ele: this.ele,
time: this.time ? new Date(this.time.getTime()) : undefined,
extensions: this.extensions ? cloneJSON(this.extensions) : undefined,
_data: {
index: this._data?.index,
anchor: this._data?.anchor,
zoom: this._data?.zoom,
},
extensions: cloneJSON(this.extensions),
_data: cloneJSON(this._data),
});
}
}
@@ -1522,28 +1484,19 @@ export class Waypoint {
type?: string;
_data: { [key: string]: any } = {};
constructor(waypoint: (WaypointType & { _data?: any }) | Waypoint, index?: number) {
constructor(waypoint: (WaypointType & { _data?: any }) | Waypoint) {
this.attributes = waypoint.attributes;
this.ele = waypoint.ele;
this.time = waypoint.time;
this.name = waypoint.name === '' ? undefined : waypoint.name;
this.cmt = waypoint.cmt === '' ? undefined : waypoint.cmt;
this.desc = waypoint.desc === '' ? undefined : waypoint.desc;
this.link =
!waypoint.link ||
!waypoint.link.attributes ||
!waypoint.link.attributes.href ||
waypoint.link.attributes.href === ''
? undefined
: waypoint.link;
this.sym = waypoint.sym === '' ? undefined : waypoint.sym;
this.type = waypoint.type === '' ? undefined : waypoint.type;
this.name = waypoint.name;
this.cmt = waypoint.cmt;
this.desc = waypoint.desc;
this.link = waypoint.link;
this.sym = waypoint.sym;
this.type = waypoint.type;
if (waypoint.hasOwnProperty('_data')) {
this._data = waypoint._data;
}
if (index !== undefined) {
this._data.index = index;
}
}
getCoordinates(): Coordinates {
@@ -1591,10 +1544,7 @@ export class Waypoint {
clone(): Waypoint {
return new Waypoint({
attributes: {
lat: this.attributes.lat,
lon: this.attributes.lon,
},
attributes: cloneJSON(this.attributes),
ele: this.ele,
time: this.time ? new Date(this.time.getTime()) : undefined,
name: this.name,
@@ -1643,6 +1593,310 @@ export class Waypoint {
}
}
export class GPXStatistics {
global: {
distance: {
moving: number;
total: number;
};
time: {
start: Date | undefined;
end: Date | undefined;
moving: number;
total: number;
};
speed: {
moving: number;
total: number;
};
elevation: {
gain: number;
loss: number;
};
bounds: {
southWest: Coordinates;
northEast: Coordinates;
};
atemp: {
avg: number;
count: number;
};
hr: {
avg: number;
count: number;
};
cad: {
avg: number;
count: number;
};
power: {
avg: number;
count: number;
};
extensions: Record<string, Record<string, number>>;
};
local: {
points: TrackPoint[];
distance: {
moving: number[];
total: number[];
};
time: {
moving: number[];
total: number[];
};
speed: number[];
elevation: {
smoothed: number[];
gain: number[];
loss: number[];
};
slope: {
at: number[];
segment: number[];
length: number[];
};
};
constructor() {
this.global = {
distance: {
moving: 0,
total: 0,
},
time: {
start: undefined,
end: undefined,
moving: 0,
total: 0,
},
speed: {
moving: 0,
total: 0,
},
elevation: {
gain: 0,
loss: 0,
},
bounds: {
southWest: {
lat: 90,
lon: 180,
},
northEast: {
lat: -90,
lon: -180,
},
},
atemp: {
avg: 0,
count: 0,
},
hr: {
avg: 0,
count: 0,
},
cad: {
avg: 0,
count: 0,
},
power: {
avg: 0,
count: 0,
},
extensions: {},
};
this.local = {
points: [],
distance: {
moving: [],
total: [],
},
time: {
moving: [],
total: [],
},
speed: [],
elevation: {
smoothed: [],
gain: [],
loss: [],
},
slope: {
at: [],
segment: [],
length: [],
},
};
}
mergeWith(other: GPXStatistics): void {
this.local.points = this.local.points.concat(other.local.points);
this.local.distance.total = this.local.distance.total.concat(
other.local.distance.total.map((distance) => distance + this.global.distance.total)
);
this.local.distance.moving = this.local.distance.moving.concat(
other.local.distance.moving.map((distance) => distance + this.global.distance.moving)
);
this.local.time.total = this.local.time.total.concat(
other.local.time.total.map((time) => time + this.global.time.total)
);
this.local.time.moving = this.local.time.moving.concat(
other.local.time.moving.map((time) => time + this.global.time.moving)
);
this.local.elevation.gain = this.local.elevation.gain.concat(
other.local.elevation.gain.map((gain) => gain + this.global.elevation.gain)
);
this.local.elevation.loss = this.local.elevation.loss.concat(
other.local.elevation.loss.map((loss) => loss + this.global.elevation.loss)
);
this.local.speed = this.local.speed.concat(other.local.speed);
this.local.elevation.smoothed = this.local.elevation.smoothed.concat(
other.local.elevation.smoothed
);
this.local.slope.at = this.local.slope.at.concat(other.local.slope.at);
this.local.slope.segment = this.local.slope.segment.concat(other.local.slope.segment);
this.local.slope.length = this.local.slope.length.concat(other.local.slope.length);
this.global.distance.total += other.global.distance.total;
this.global.distance.moving += other.global.distance.moving;
this.global.time.start =
this.global.time.start !== undefined && other.global.time.start !== undefined
? new Date(
Math.min(this.global.time.start.getTime(), other.global.time.start.getTime())
)
: (this.global.time.start ?? other.global.time.start);
this.global.time.end =
this.global.time.end !== undefined && other.global.time.end !== undefined
? new Date(
Math.max(this.global.time.end.getTime(), other.global.time.end.getTime())
)
: (this.global.time.end ?? other.global.time.end);
this.global.time.total += other.global.time.total;
this.global.time.moving += other.global.time.moving;
this.global.speed.moving =
this.global.time.moving > 0
? this.global.distance.moving / (this.global.time.moving / 3600)
: 0;
this.global.speed.total =
this.global.time.total > 0
? this.global.distance.total / (this.global.time.total / 3600)
: 0;
this.global.elevation.gain += other.global.elevation.gain;
this.global.elevation.loss += other.global.elevation.loss;
this.global.bounds.southWest.lat = Math.min(
this.global.bounds.southWest.lat,
other.global.bounds.southWest.lat
);
this.global.bounds.southWest.lon = Math.min(
this.global.bounds.southWest.lon,
other.global.bounds.southWest.lon
);
this.global.bounds.northEast.lat = Math.max(
this.global.bounds.northEast.lat,
other.global.bounds.northEast.lat
);
this.global.bounds.northEast.lon = Math.max(
this.global.bounds.northEast.lon,
other.global.bounds.northEast.lon
);
this.global.atemp.avg =
(this.global.atemp.count * this.global.atemp.avg +
other.global.atemp.count * other.global.atemp.avg) /
Math.max(1, this.global.atemp.count + other.global.atemp.count);
this.global.atemp.count += other.global.atemp.count;
this.global.hr.avg =
(this.global.hr.count * this.global.hr.avg +
other.global.hr.count * other.global.hr.avg) /
Math.max(1, this.global.hr.count + other.global.hr.count);
this.global.hr.count += other.global.hr.count;
this.global.cad.avg =
(this.global.cad.count * this.global.cad.avg +
other.global.cad.count * other.global.cad.avg) /
Math.max(1, this.global.cad.count + other.global.cad.count);
this.global.cad.count += other.global.cad.count;
this.global.power.avg =
(this.global.power.count * this.global.power.avg +
other.global.power.count * other.global.power.avg) /
Math.max(1, this.global.power.count + other.global.power.count);
this.global.power.count += other.global.power.count;
Object.keys(other.global.extensions).forEach((extension) => {
if (this.global.extensions[extension] === undefined) {
this.global.extensions[extension] = {};
}
Object.keys(other.global.extensions[extension]).forEach((value) => {
if (this.global.extensions[extension][value] === undefined) {
this.global.extensions[extension][value] = 0;
}
this.global.extensions[extension][value] +=
other.global.extensions[extension][value];
});
});
}
slice(start: number, end: number): GPXStatistics {
if (start < 0) {
start = 0;
} else if (start >= this.local.points.length) {
return new GPXStatistics();
}
if (end < start) {
return new GPXStatistics();
} else if (end >= this.local.points.length) {
end = this.local.points.length - 1;
}
let statistics = new GPXStatistics();
statistics.local.points = this.local.points.slice(start, end + 1);
statistics.global.distance.total =
this.local.distance.total[end] - this.local.distance.total[start];
statistics.global.distance.moving =
this.local.distance.moving[end] - this.local.distance.moving[start];
statistics.global.time.start = this.local.points[start].time;
statistics.global.time.end = this.local.points[end].time;
statistics.global.time.total = this.local.time.total[end] - this.local.time.total[start];
statistics.global.time.moving = this.local.time.moving[end] - this.local.time.moving[start];
statistics.global.speed.moving =
statistics.global.time.moving > 0
? statistics.global.distance.moving / (statistics.global.time.moving / 3600)
: 0;
statistics.global.speed.total =
statistics.global.time.total > 0
? statistics.global.distance.total / (statistics.global.time.total / 3600)
: 0;
statistics.global.elevation.gain =
this.local.elevation.gain[end] - this.local.elevation.gain[start];
statistics.global.elevation.loss =
this.local.elevation.loss[end] - this.local.elevation.loss[start];
statistics.global.bounds.southWest.lat = this.global.bounds.southWest.lat;
statistics.global.bounds.southWest.lon = this.global.bounds.southWest.lon;
statistics.global.bounds.northEast.lat = this.global.bounds.northEast.lat;
statistics.global.bounds.northEast.lon = this.global.bounds.northEast.lon;
statistics.global.atemp = this.global.atemp;
statistics.global.hr = this.global.hr;
statistics.global.cad = this.global.cad;
statistics.global.power = this.global.power;
return statistics;
}
}
const earthRadius = 6371008.8;
export function distance(
coord1: TrackPoint | Coordinates,
@@ -1657,15 +1911,11 @@ export function distance(
const rad = Math.PI / 180;
const lat1 = coord1.lat * rad;
const lat2 = coord2.lat * rad;
const dLat = lat2 - lat1;
const dLon = (coord2.lon - coord1.lon) * rad;
// Haversine formula - better numerical stability for small distances
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.asin(Math.sqrt(Math.min(a, 1)));
return earthRadius * c;
Math.sin(lat1) * Math.sin(lat2) +
Math.cos(lat1) * Math.cos(lat2) * Math.cos((coord2.lon - coord1.lon) * rad);
const maxMeters = earthRadius * Math.acos(Math.min(a, 1));
return maxMeters;
}
export function getElevationDistanceFunction(statistics: GPXStatistics) {
@@ -1676,9 +1926,9 @@ export function getElevationDistanceFunction(statistics: GPXStatistics) {
if (point1.ele === undefined || point2.ele === undefined || point3.ele === undefined) {
return 0;
}
let x1 = statistics.local.data[point1._data.index].distance.total * 1000;
let x2 = statistics.local.data[point2._data.index].distance.total * 1000;
let x3 = statistics.local.data[point3._data.index].distance.total * 1000;
let x1 = statistics.local.distance.total[point1._data.index] * 1000;
let x2 = statistics.local.distance.total[point2._data.index] * 1000;
let x3 = statistics.local.distance.total[point3._data.index] * 1000;
let y1 = point1.ele;
let y2 = point2.ele;
let y3 = point3.ele;
@@ -1692,61 +1942,57 @@ export function getElevationDistanceFunction(statistics: GPXStatistics) {
};
}
function windowSmoothing(
left: number,
right: number,
distance: (index1: number, index2: number) => number,
window: number,
compute: (start: number, end: number) => number,
callback: (value: number, index: number) => void
): void {
let start = left;
for (var i = left; i < right; i++) {
while (start + 1 < i && distance(start, i) > window) {
function distanceWindowSmoothing(
points: TrackPoint[],
distanceWindow: number,
accumulate: (index: number) => number,
compute: (accumulated: number, start: number, end: number) => number,
remove?: (index: number) => number
): number[] {
let result = [];
let start = 0,
end = 0,
accumulated = 0;
for (var i = 0; i < points.length; i++) {
while (
start + 1 < i &&
distance(points[start].getCoordinates(), points[i].getCoordinates()) > distanceWindow
) {
if (remove) {
accumulated -= remove(start);
} else {
accumulated -= accumulate(start);
}
start++;
}
let end = Math.min(i + 2, right);
while (end < right && distance(i, end) <= window) {
while (
end < points.length &&
distance(points[i].getCoordinates(), points[end].getCoordinates()) <= distanceWindow
) {
accumulated += accumulate(end);
end++;
}
callback(compute(start, end - 1), i);
result[i] = compute(accumulated, start, end - 1);
}
return result;
}
function distanceWindowSmoothing(
left: number,
right: number,
statistics: GPXStatistics,
window: number,
compute: (start: number, end: number) => number,
callback: (value: number, index: number) => void
): void {
windowSmoothing(
left,
right,
(index1, index2) =>
statistics.local.data[index2].distance.total -
statistics.local.data[index1].distance.total,
window,
compute,
callback
);
}
function timeWindowSmoothing(
function distanceWindowSmoothingWithDistanceAccumulator(
points: TrackPoint[],
window: number,
compute: (start: number, end: number) => number,
callback: (value: number, index: number) => void
): void {
windowSmoothing(
0,
points.length,
(index1, index2) =>
points[index2].time?.getTime() - points[index1].time?.getTime() || 2 * window,
window,
distanceWindow: number,
compute: (accumulated: number, start: number, end: number) => number
): number[] {
return distanceWindowSmoothing(
points,
distanceWindow,
(index) =>
index > 0
? distance(points[index - 1].getCoordinates(), points[index].getCoordinates())
: 0,
compute,
callback
(index) => distance(points[index].getCoordinates(), points[index + 1].getCoordinates())
);
}
@@ -1798,14 +2044,14 @@ function withArtificialTimestamps(
totalTime: number,
lastPoint: TrackPoint | undefined,
startTime: Date,
statistics: GPXStatistics
slope: number[]
): TrackPoint[] {
let weight = [];
let totalWeight = 0;
for (let i = 0; i < points.length - 1; i++) {
let dist = distance(points[i].getCoordinates(), points[i + 1].getCoordinates());
let w = dist * (0.5 + 1 / (1 + Math.exp(-0.2 * statistics.local.data[i].slope.at)));
let w = dist * (0.5 + 1 / (1 + Math.exp(-0.2 * slope[i])));
weight.push(w);
totalWeight += w;
}
-1
View File
@@ -1,5 +1,4 @@
export * from './gpx';
export * from './statistics';
export { Coordinates, LineStyleExtension, WaypointType } from './types';
export { parseGPX, buildGPX } from './io';
export * from './simplify';
+98 -59
View File
@@ -3,6 +3,8 @@ import { Coordinates } from './types';
export type SimplifiedTrackPoint = { point: TrackPoint; distance?: number };
const earthRadius = 6371008.8;
export function ramerDouglasPeucker(
points: TrackPoint[],
epsilon: number = 50,
@@ -59,56 +61,76 @@ function ramerDouglasPeuckerRecursive(
}
export function crossarcDistance(
point1: TrackPoint | Coordinates,
point2: TrackPoint | Coordinates,
point1: TrackPoint,
point2: TrackPoint,
point3: TrackPoint | Coordinates
): number {
return crossarc(
point1 instanceof TrackPoint ? point1.getCoordinates() : point1,
point2 instanceof TrackPoint ? point2.getCoordinates() : point2,
point1.getCoordinates(),
point2.getCoordinates(),
point3 instanceof TrackPoint ? point3.getCoordinates() : point3
);
}
const metersPerLatitudeDegree = 111320;
function getMetersPerLongitudeDegree(latitude: number): number {
return Math.cos((latitude * Math.PI) / 180) * metersPerLatitudeDegree;
}
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
// Calculates the perpendicular distance in meters
// between a line segment (defined by p1 and p2) and a third point, p3.
// Uses simple planar geometry (ignores earth curvature).
// Calculates the shortest distance in meters
// between an arc (defined by p1 and p2) and a third point, p3.
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
// Convert to meters using approximate scaling
const metersPerLongitudeDegree = getMetersPerLongitudeDegree(coord1.lat);
const rad = Math.PI / 180;
const lat1 = coord1.lat * rad;
const lat2 = coord2.lat * rad;
const lat3 = coord3.lat * rad;
const x1 = coord1.lon * metersPerLongitudeDegree;
const y1 = coord1.lat * metersPerLatitudeDegree;
const x2 = coord2.lon * metersPerLongitudeDegree;
const y2 = coord2.lat * metersPerLatitudeDegree;
const x3 = coord3.lon * metersPerLongitudeDegree;
const y3 = coord3.lat * metersPerLatitudeDegree;
const lon1 = coord1.lon * rad;
const lon2 = coord2.lon * rad;
const lon3 = coord3.lon * rad;
const dx = x2 - x1;
const dy = y2 - y1;
const segmentLengthSquared = dx * dx + dy * dy;
// Prerequisites for the formulas
const bear12 = bearing(lat1, lon1, lat2, lon2);
const bear13 = bearing(lat1, lon1, lat3, lon3);
let dis13 = distance(lat1, lon1, lat3, lon3);
if (segmentLengthSquared === 0) {
// p1 and p2 are the same point
return Math.sqrt((x3 - x1) * (x3 - x1) + (y3 - y1) * (y3 - y1));
let diff = Math.abs(bear13 - bear12);
if (diff > Math.PI) {
diff = 2 * Math.PI - diff;
}
// Project p3 onto the line defined by p1-p2
const t = Math.max(0, Math.min(1, ((x3 - x1) * dx + (y3 - y1) * dy) / segmentLengthSquared));
// Is relative bearing obtuse?
if (diff > Math.PI / 2) {
return dis13;
}
// Find the closest point on the segment
const projX = x1 + t * dx;
const projY = y1 + t * dy;
// Find the cross-track distance.
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
// Return distance from p3 to the projected point
return Math.sqrt((x3 - projX) * (x3 - projX) + (y3 - projY) * (y3 - projY));
// Is p4 beyond the arc?
let dis12 = distance(lat1, lon1, lat2, lon2);
let dis14 =
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
if (dis14 > dis12) {
return distance(lat2, lon2, lat3, lon3);
} else {
return Math.abs(dxt);
}
}
function distance(latA: number, lonA: number, latB: number, lonB: number): number {
// Finds the distance between two lat / lon points.
return (
Math.acos(
Math.sin(latA) * Math.sin(latB) +
Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
) * earthRadius
);
}
function bearing(latA: number, lonA: number, latB: number, lonB: number): number {
// Finds the bearing from one lat / lon point to another.
return Math.atan2(
Math.sin(lonB - lonA) * Math.cos(latB),
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
);
}
export function projectedPoint(
@@ -124,39 +146,56 @@ export function projectedPoint(
}
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
// Calculates the point on the line segment defined by p1 and p2
// Calculates the point on the line defined by p1 and p2
// that is closest to the third point, p3.
// Uses simple planar geometry (ignores earth curvature).
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
// Convert to meters using approximate scaling
const metersPerLongitudeDegree = getMetersPerLongitudeDegree(coord1.lat);
const rad = Math.PI / 180;
const lat1 = coord1.lat * rad;
const lat2 = coord2.lat * rad;
const lat3 = coord3.lat * rad;
const x1 = coord1.lon * metersPerLongitudeDegree;
const y1 = coord1.lat * metersPerLatitudeDegree;
const x2 = coord2.lon * metersPerLongitudeDegree;
const y2 = coord2.lat * metersPerLatitudeDegree;
const x3 = coord3.lon * metersPerLongitudeDegree;
const y3 = coord3.lat * metersPerLatitudeDegree;
const lon1 = coord1.lon * rad;
const lon2 = coord2.lon * rad;
const lon3 = coord3.lon * rad;
const dx = x2 - x1;
const dy = y2 - y1;
const segmentLengthSquared = dx * dx + dy * dy;
// Prerequisites for the formulas
const bear12 = bearing(lat1, lon1, lat2, lon2);
const bear13 = bearing(lat1, lon1, lat3, lon3);
let dis13 = distance(lat1, lon1, lat3, lon3);
if (segmentLengthSquared === 0) {
// p1 and p2 are the same point
let diff = Math.abs(bear13 - bear12);
if (diff > Math.PI) {
diff = 2 * Math.PI - diff;
}
// Is relative bearing obtuse?
if (diff > Math.PI / 2) {
return coord1;
}
// Project p3 onto the line defined by p1-p2
const t = Math.max(0, Math.min(1, ((x3 - x1) * dx + (y3 - y1) * dy) / segmentLengthSquared));
// Find the cross-track distance.
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
// Find the closest point on the segment
const projX = x1 + t * dx;
const projY = y1 + t * dy;
// Is p4 beyond the arc?
let dis12 = distance(lat1, lon1, lat2, lon2);
let dis14 =
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
if (dis14 > dis12) {
return coord2;
} else {
// Determine the closest point (p4) on the great circle
const f = dis14 / earthRadius;
const lat4 = Math.asin(
Math.sin(lat1) * Math.cos(f) + Math.cos(lat1) * Math.sin(f) * Math.cos(bear12)
);
const lon4 =
lon1 +
Math.atan2(
Math.sin(bear12) * Math.sin(f) * Math.cos(lat1),
Math.cos(f) - Math.sin(lat1) * Math.sin(lat4)
);
// Convert back to degrees
return {
lat: projY / metersPerLatitudeDegree,
lon: projX / metersPerLongitudeDegree,
};
return { lat: lat4 / rad, lon: lon4 / rad };
}
}
-391
View File
@@ -1,391 +0,0 @@
import { TrackPoint } from './gpx';
import { Coordinates } from './types';
export class GPXGlobalStatistics {
length: number;
distance: {
moving: number;
total: number;
};
time: {
start: Date | undefined;
end: Date | undefined;
moving: number;
total: number;
};
speed: {
moving: number;
total: number;
};
elevation: {
gain: number;
loss: number;
};
bounds: {
southWest: Coordinates;
northEast: Coordinates;
};
atemp: {
avg: number;
count: number;
};
hr: {
avg: number;
count: number;
};
cad: {
avg: number;
count: number;
};
power: {
avg: number;
count: number;
};
extensions: Record<string, Record<string, number>>;
constructor() {
this.length = 0;
this.distance = {
moving: 0,
total: 0,
};
this.time = {
start: undefined,
end: undefined,
moving: 0,
total: 0,
};
this.speed = {
moving: 0,
total: 0,
};
this.elevation = {
gain: 0,
loss: 0,
};
this.bounds = {
southWest: {
lat: 90,
lon: 180,
},
northEast: {
lat: -90,
lon: -180,
},
};
this.atemp = {
avg: 0,
count: 0,
};
this.hr = {
avg: 0,
count: 0,
};
this.cad = {
avg: 0,
count: 0,
};
this.power = {
avg: 0,
count: 0,
};
this.extensions = {};
}
mergeWith(other: GPXGlobalStatistics): void {
this.length += other.length;
this.distance.total += other.distance.total;
this.distance.moving += other.distance.moving;
this.time.start =
this.time.start !== undefined && other.time.start !== undefined
? new Date(Math.min(this.time.start.getTime(), other.time.start.getTime()))
: (this.time.start ?? other.time.start);
this.time.end =
this.time.end !== undefined && other.time.end !== undefined
? new Date(Math.max(this.time.end.getTime(), other.time.end.getTime()))
: (this.time.end ?? other.time.end);
this.time.total += other.time.total;
this.time.moving += other.time.moving;
this.speed.moving =
this.time.moving > 0 ? this.distance.moving / (this.time.moving / 3600) : 0;
this.speed.total = this.time.total > 0 ? this.distance.total / (this.time.total / 3600) : 0;
this.elevation.gain += other.elevation.gain;
this.elevation.loss += other.elevation.loss;
this.bounds.southWest.lat = Math.min(this.bounds.southWest.lat, other.bounds.southWest.lat);
this.bounds.southWest.lon = Math.min(this.bounds.southWest.lon, other.bounds.southWest.lon);
this.bounds.northEast.lat = Math.max(this.bounds.northEast.lat, other.bounds.northEast.lat);
this.bounds.northEast.lon = Math.max(this.bounds.northEast.lon, other.bounds.northEast.lon);
this.atemp.avg =
(this.atemp.count * this.atemp.avg + other.atemp.count * other.atemp.avg) /
Math.max(1, this.atemp.count + other.atemp.count);
this.atemp.count += other.atemp.count;
this.hr.avg =
(this.hr.count * this.hr.avg + other.hr.count * other.hr.avg) /
Math.max(1, this.hr.count + other.hr.count);
this.hr.count += other.hr.count;
this.cad.avg =
(this.cad.count * this.cad.avg + other.cad.count * other.cad.avg) /
Math.max(1, this.cad.count + other.cad.count);
this.cad.count += other.cad.count;
this.power.avg =
(this.power.count * this.power.avg + other.power.count * other.power.avg) /
Math.max(1, this.power.count + other.power.count);
this.power.count += other.power.count;
Object.keys(other.extensions).forEach((extension) => {
if (this.extensions[extension] === undefined) {
this.extensions[extension] = {};
}
Object.keys(other.extensions[extension]).forEach((value) => {
if (this.extensions[extension][value] === undefined) {
this.extensions[extension][value] = 0;
}
this.extensions[extension][value] += other.extensions[extension][value];
});
});
}
}
export class TrackPointLocalStatistics {
distance: {
moving: number;
total: number;
};
time: {
moving: number;
total: number;
};
speed: number;
elevation: {
gain: number;
loss: number;
};
slope: {
at: number;
segment: number;
length: number;
};
constructor() {
this.distance = {
moving: 0,
total: 0,
};
this.time = {
moving: 0,
total: 0,
};
this.speed = 0;
this.elevation = {
gain: 0,
loss: 0,
};
this.slope = {
at: 0,
segment: 0,
length: 0,
};
}
}
export class GPXLocalStatistics {
points: TrackPoint[];
data: TrackPointLocalStatistics[];
constructor() {
this.points = [];
this.data = [];
}
}
export type TrackPointWithLocalStatistics = {
trkpt: TrackPoint;
} & TrackPointLocalStatistics;
export class GPXStatistics {
global: GPXGlobalStatistics;
local: GPXLocalStatistics;
constructor() {
this.global = new GPXGlobalStatistics();
this.local = new GPXLocalStatistics();
}
sliced(start: number, end: number): GPXGlobalStatistics {
if (start < 0) {
start = 0;
} else if (start >= this.global.length) {
return new GPXGlobalStatistics();
}
if (end < start) {
return new GPXGlobalStatistics();
} else if (end >= this.global.length) {
end = this.global.length - 1;
}
if (start === 0 && end === this.global.length - 1) {
return this.global;
}
let statistics = new GPXGlobalStatistics();
statistics.length = end - start + 1;
statistics.distance.total =
this.local.data[end].distance.total - this.local.data[start].distance.total;
statistics.distance.moving =
this.local.data[end].distance.moving - this.local.data[start].distance.moving;
statistics.time.start = this.local.points[start].time;
statistics.time.end = this.local.points[end].time;
statistics.time.total = this.local.data[end].time.total - this.local.data[start].time.total;
statistics.time.moving =
this.local.data[end].time.moving - this.local.data[start].time.moving;
statistics.speed.moving =
statistics.time.moving > 0
? statistics.distance.moving / (statistics.time.moving / 3600)
: 0;
statistics.speed.total =
statistics.time.total > 0
? statistics.distance.total / (statistics.time.total / 3600)
: 0;
statistics.elevation.gain =
this.local.data[end].elevation.gain - this.local.data[start].elevation.gain;
statistics.elevation.loss =
this.local.data[end].elevation.loss - this.local.data[start].elevation.loss;
statistics.bounds.southWest.lat = this.global.bounds.southWest.lat;
statistics.bounds.southWest.lon = this.global.bounds.southWest.lon;
statistics.bounds.northEast.lat = this.global.bounds.northEast.lat;
statistics.bounds.northEast.lon = this.global.bounds.northEast.lon;
statistics.atemp = this.global.atemp;
statistics.hr = this.global.hr;
statistics.cad = this.global.cad;
statistics.power = this.global.power;
return statistics;
}
}
export class GPXStatisticsGroup {
private _statistics: GPXStatistics[];
private _cumulative: GPXGlobalStatistics[];
private _slice: [number, number] | null = null;
global: GPXGlobalStatistics;
constructor() {
this._statistics = [];
this._cumulative = [new GPXGlobalStatistics()];
this.global = new GPXGlobalStatistics();
}
add(statistics: GPXStatistics | GPXStatisticsGroup): void {
if (statistics instanceof GPXStatisticsGroup) {
statistics._statistics.forEach((stats) => this._add(stats));
} else {
this._add(statistics);
}
}
_add(statistics: GPXStatistics): void {
this._statistics.push(statistics);
const cumulative = new GPXGlobalStatistics();
cumulative.mergeWith(this._cumulative[this._cumulative.length - 1]);
cumulative.mergeWith(statistics.global);
this._cumulative.push(cumulative);
this.global.mergeWith(statistics.global);
}
sliced(start: number, end: number): GPXGlobalStatistics {
let sliced = new GPXGlobalStatistics();
for (let i = 0; i < this._statistics.length; i++) {
const statistics = this._statistics[i];
const cumulative = this._cumulative[i];
if (start < cumulative.length + statistics.global.length && end >= cumulative.length) {
const localStart = Math.max(0, start - cumulative.length);
const localEnd = Math.min(statistics.global.length - 1, end - cumulative.length);
sliced.mergeWith(statistics.sliced(localStart, localEnd));
}
}
return sliced;
}
getTrackPoint(index: number): TrackPointWithLocalStatistics | undefined {
if (this._slice !== null) {
index += this._slice[0];
}
for (let i = 0; i < this._statistics.length; i++) {
const statistics = this._statistics[i];
const cumulative = this._cumulative[i];
if (index < cumulative.length + statistics.global.length) {
return this._getTrackPoint(cumulative, statistics, index - cumulative.length);
}
}
return undefined;
}
_getTrackPoint(
cumulative: GPXGlobalStatistics,
statistics: GPXStatistics,
index: number
): TrackPointWithLocalStatistics {
const point = statistics.local.points[index];
return {
trkpt: point,
distance: {
moving: statistics.local.data[index].distance.moving + cumulative.distance.moving,
total: statistics.local.data[index].distance.total + cumulative.distance.total,
},
time: {
moving: statistics.local.data[index].time.moving + cumulative.time.moving,
total: statistics.local.data[index].time.total + cumulative.time.total,
},
speed: statistics.local.data[index].speed,
elevation: {
gain: statistics.local.data[index].elevation.gain + cumulative.elevation.gain,
loss: statistics.local.data[index].elevation.loss + cumulative.elevation.loss,
},
slope: {
at: statistics.local.data[index].slope.at,
segment: statistics.local.data[index].slope.segment,
length: statistics.local.data[index].slope.length,
},
};
}
forEachTrackPoint(
callback: (
point: TrackPoint,
distance: number,
speed: number,
slope: { at: number; segment: number; length: number },
index: number
) => void
): void {
for (let i = 0; i < this._statistics.length; i++) {
const statistics = this._statistics[i];
const cumulative = this._cumulative[i];
statistics.local.points.forEach((point, index) =>
callback(
point,
cumulative.distance.total + statistics.local.data[index].distance.total,
statistics.local.data[index].speed,
statistics.local.data[index].slope,
cumulative.length + index
)
);
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "gpx.studio",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+1 -1
View File
@@ -1 +1 @@
PUBLIC_MAPTILER_KEY=YOUR_MAPTILER_KEY
PUBLIC_MAPBOX_TOKEN=YOUR_MAPBOX_TOKEN
+3 -6
View File
@@ -1,8 +1,9 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"css": "src/app.css",
"baseColor": "neutral"
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
@@ -12,9 +13,5 @@
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry",
"style": "nova",
"iconLibrary": "lucide",
"menuColor": "default",
"menuAccent": "subtle"
"registry": "https://shadcn-svelte.com/registry"
}
+4810 -3838
View File
File diff suppressed because it is too large Load Diff
+20 -18
View File
@@ -10,13 +10,11 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .",
"format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore"
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.7.0",
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.6.0",
"@sveltejs/kit": "^2.21.2",
@@ -25,15 +23,15 @@
"@types/eslint": "^9.6.1",
"@types/events": "^3.0.3",
"@types/file-saver": "^2.0.7",
"@types/mapbox__sphericalmercator": "^1.2.3",
"@types/mapbox__tilebelt": "^1.0.4",
"@types/mapbox-gl": "^3.4.1",
"@types/node": "^22.15.30",
"@types/png.js": "^0.2.3",
"@types/sanitize-html": "^2.16.0",
"@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^8.33.1",
"@typescript-eslint/parser": "^8.33.1",
"bits-ui": "^2.17.2",
"clsx": "^2.1.1",
"bits-ui": "^2.12.0",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.9.1",
@@ -46,37 +44,41 @@
"postcss": "^8.4.47",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.4.0",
"shadcn-svelte": "^1.2.7",
"svelte": "^5.33.18",
"svelte-check": "^4.0.0",
"svelte-dnd-action": "^0.9.65",
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"svelte-sonner": "^1.0.5",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.1.8",
"tslib": "^2.8.1",
"tsx": "^4.19.1",
"tw-animate-css": "^1.4.0",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vite-plugin-node-polyfills": "^0.23.0"
},
"type": "module",
"dependencies": {
"@docsearch/js": "^3.9.0",
"@internationalized/date": "^3.8.2",
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
"@mapbox/sphericalmercator": "^2.0.1",
"@mapbox/tilebelt": "^2.0.2",
"@maplibre/maplibre-gl-geocoder": "^1.9.4",
"chart.js": "^4.5.1",
"@types/mapbox__sphericalmercator": "^1.2.3",
"chart.js": "^4.4.9",
"chartjs-plugin-zoom": "^2.2.0",
"clsx": "^2.1.1",
"dexie": "^4.0.11",
"file-saver": "^2.0.5",
"gpx": "file:../gpx",
"immer": "^10.1.1",
"jszip": "^3.10.1",
"mapbox-gl": "^3.12.0",
"mapillary-js": "^4.1.2",
"maplibre-gl": "^5.21.1",
"png.js": "^0.2.1",
"sanitize-html": "^2.17.0",
"sortablejs": "^1.15.6"
"sortablejs": "^1.15.6",
"tailwind-merge": "^3.3.0"
}
}
+104 -144
View File
@@ -1,164 +1,124 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import "shadcn-svelte/tailwind.css";
@import "@fontsource-variable/inter";
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: hsl(0 0% 98%);
--ring: oklch(0.708 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--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);
--selection: hsl(240 4.8% 93%);
--support: rgb(220 15 130);
--link: rgb(0 110 180);
--radius: 0.5rem;
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.5rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: hsl(0 0% 98%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--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);
--selection: hsl(240 3.7% 22%);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--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);
/* 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);
/* 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;
--font-sans: 'Inter Variable', sans-serif;
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--breakpoint-xs: 540px;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
body {
@apply bg-background text-foreground;
}
}
-8
View File
@@ -24,14 +24,6 @@ export async function handle({ event, resolve }) {
let headTag = `<head>
<title>gpx.studio — ${title}</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "gpx.studio",
"url": "https://gpx.studio"
}
</script>
<meta name="description" content="${description}" />
<meta property="og:title" content="gpx.studio — ${title}" />
<meta property="og:description" content="${description}" />
@@ -17,6 +17,7 @@
}
},
"sprite": "https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite",
"glyphs": "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key={key}",
"layers": [
{
"id": "background",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 729 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 525 KiB

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 710 KiB

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

+17 -128
View File
@@ -22,39 +22,15 @@ import {
Binoculars,
Toilet,
} from 'lucide-static';
import { type RasterDEMSourceSpecification, type StyleSpecification } from 'maplibre-gl';
import { type StyleSpecification } from 'mapbox-gl';
import ignFrTopo from './custom/ign-fr-topo.json';
import ignFrPlan from './custom/ign-fr-plan.json';
import ignFrSatellite from './custom/ign-fr-satellite.json';
import bikerouterGravel from './custom/bikerouter-gravel.json';
export const maptilerKeyPlaceHolder = 'MAPTILER_KEY';
export const basemaps: { [key: string]: string | StyleSpecification } = {
topo: 'https://raw.githubusercontent.com/gpxstudio/styles/refs/heads/main/topo.json',
satellite: 'https://raw.githubusercontent.com/gpxstudio/styles/refs/heads/main/satellite.json',
esriSatellite: {
version: 8,
sources: {
esriSatellite: {
type: 'raster',
tiles: [
'https://services.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/WMTS/tile/1.0.0/World_Imagery/default/default028mm/{z}/{y}/{x}.jpg',
],
tileSize: 256,
maxzoom: 19,
attribution:
'© <a href="https://www.esri.com/" target="_blank">Esri</a>, Vantor, Earthstar Geographics, and the GIS User Community',
},
},
layers: [
{
id: 'esriSatellite',
type: 'raster',
source: 'esriSatellite',
},
],
},
mapboxOutdoors: 'mapbox://styles/mapbox/outdoors-v12',
mapboxSatellite: 'mapbox://styles/mapbox/satellite-streets-v12',
openStreetMap: {
version: 8,
sources: {
@@ -169,7 +145,7 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
swisstopoVector: 'https://vectortiles.geo.admin.ch/styles/ch.swisstopo.basemap.vt/style.json',
swisstopoSatellite:
'https://vectortiles.geo.admin.ch/styles/ch.swisstopo.imagerybasemap.vt/style.json',
linz: 'https://basemaps.linz.govt.nz/v1/styles/topographic-v2.json?api=d01fbtg0ar23gctac5m0jgyy2ds',
linz: 'https://basemaps.linz.govt.nz/v1/tiles/topographic/EPSG:3857/style/topographic.json?api=d01fbtg0ar23gctac5m0jgyy2ds',
linzTopo: {
version: 8,
sources: {
@@ -392,42 +368,6 @@ export const overlays: { [key: string]: string | StyleSpecification } = {
],
},
bikerouterGravel: bikerouterGravel as StyleSpecification,
openRailwayMap: {
version: 8,
sources: {
openRailwayMap: {
type: 'raster',
tiles: ['https://tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 19,
attribution:
'Data <a href="https://www.openstreetmap.org/copyright">&copy; OpenStreetMap contributors</a>, Style: <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA 2.0</a> <a href="http://www.openrailwaymap.org/">OpenRailwayMap</a>',
},
},
layers: [
{
id: 'openRailwayMap',
type: 'raster',
source: 'openRailwayMap',
},
],
},
mapterhornHillshade: {
version: 8,
sources: {
mapterhornHillshade: {
type: 'raster-dem',
url: 'https://tiles.mapterhorn.com/tilejson.json',
},
},
layers: [
{
id: 'mapterhornHillshade',
type: 'hillshade',
source: 'mapterhornHillshade',
},
],
},
swisstopoSlope: {
version: 8,
sources: {
@@ -797,9 +737,8 @@ export type LayerTreeType = { [key: string]: LayerTreeType | boolean };
export const basemapTree: LayerTreeType = {
basemaps: {
world: {
topo: true,
satellite: true,
esriSatellite: true,
mapboxOutdoors: true,
mapboxSatellite: true,
openStreetMap: true,
openTopoMap: true,
openHikingMap: true,
@@ -860,10 +799,8 @@ export const overlayTree: LayerTreeType = {
waymarkedTrailsHorseRiding: true,
waymarkedTrailsWinter: true,
},
bikerouterGravel: true,
cyclOSMlite: true,
mapterhornHillshade: true,
openRailwayMap: true,
bikerouterGravel: true,
},
countries: {
france: {
@@ -899,7 +836,6 @@ export const overpassTree: LayerTreeType = {
shower: true,
shelter: true,
barrier: true,
cemetery: true,
},
tourism: {
attraction: true,
@@ -932,7 +868,7 @@ export const overpassTree: LayerTreeType = {
};
// Default basemap used
export const defaultBasemap = 'topo';
export const defaultBasemap = 'mapboxOutdoors';
// Default overlays used (none)
export const defaultOverlays: LayerTreeType = {
@@ -946,10 +882,8 @@ export const defaultOverlays: LayerTreeType = {
waymarkedTrailsHorseRiding: false,
waymarkedTrailsWinter: false,
},
bikerouterGravel: false,
cyclOSMlite: false,
mapterhornHillshade: false,
openRailwayMap: false,
bikerouterGravel: false,
},
countries: {
france: {
@@ -985,7 +919,6 @@ export const defaultOverpassQueries: LayerTreeType = {
shower: false,
shelter: false,
barrier: false,
cemetery: false,
},
tourism: {
attraction: false,
@@ -1021,9 +954,8 @@ export const defaultOverpassQueries: LayerTreeType = {
export const defaultBasemapTree: LayerTreeType = {
basemaps: {
world: {
topo: true,
satellite: true,
esriSatellite: false,
mapboxOutdoors: true,
mapboxSatellite: true,
openStreetMap: true,
openTopoMap: true,
openHikingMap: true,
@@ -1084,10 +1016,8 @@ export const defaultOverlayTree: LayerTreeType = {
waymarkedTrailsHorseRiding: false,
waymarkedTrailsWinter: false,
},
bikerouterGravel: false,
cyclOSMlite: false,
mapterhornHillshade: false,
openRailwayMap: false,
bikerouterGravel: false,
},
countries: {
france: {
@@ -1123,7 +1053,6 @@ export const defaultOverpassTree: LayerTreeType = {
shower: false,
shelter: false,
barrier: false,
cemetery: false,
},
tourism: {
attraction: false,
@@ -1162,7 +1091,7 @@ export type CustomLayer = {
maxZoom: number;
layerType: 'basemap' | 'overlay';
resourceType: 'raster' | 'vector';
value: string | maplibregl.StyleSpecification;
value: string | {};
};
type OverpassQueryData = {
@@ -1170,7 +1099,9 @@ type OverpassQueryData = {
svg: string;
color: string;
};
tags: Record<string, string | string[]> | Record<string, string | string[]>[];
tags:
| Record<string, string | boolean | string[]>
| Record<string, string | boolean | string[]>[];
symbol?: string;
};
@@ -1251,20 +1182,6 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
symbol: 'Shelter',
},
cemetery: {
icon: {
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 17v-10a6 5 0 1 1 12 0v10"/><path d="M 4 21 a 1 1 0 0 0 1 1 h 14 a 1 1 0 0 0 1-1 v -1 a 2 2 0 0 0-2-2 H6 a 2 2 0 0 0-2 2 z"/></svg>',
color: '#000000',
},
tags: [
{
landuse: 'cemetery',
},
{
amenity: 'grave_yard',
},
],
},
'fuel-station': {
icon: {
svg: Fuel,
@@ -1301,25 +1218,7 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
color: '#000000',
},
tags: {
barrier: [
'bar',
'barrier_board',
'block',
'chain',
'cycle_barrier',
'gate',
'hampshire_gate',
'horse_stile',
'kissing_gate',
'lift_gate',
'motorcycle_barrier',
'sliding_beam',
'sliding_gate',
'stile',
'swing_gate',
'turnstile',
'wicket_gate',
],
barrier: true,
},
},
attraction: {
@@ -1479,13 +1378,3 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
symbol: 'Anchor',
},
};
export const terrainSources: { [key: string]: RasterDEMSourceSpecification } = {
mapterhorn: {
type: 'raster-dem',
url: 'https://tiles.gpx.studio/mapterhorn.json',
encoding: 'terrarium',
},
};
export const defaultTerrainSource = 'mapterhorn';
+2 -7
View File
@@ -1,5 +1,6 @@
import {
Landmark,
Icon,
Shell,
Bike,
Building,
@@ -28,7 +29,6 @@ import {
TriangleAlert,
Anchor,
Toilet,
X,
type IconProps,
} from '@lucide/svelte';
import {
@@ -61,7 +61,6 @@ import {
TriangleAlert as TriangleAlertSvg,
Anchor as AnchorSvg,
Toilet as ToiletSvg,
X as XSvg,
} from 'lucide-static';
import type { Component } from 'svelte';
@@ -88,11 +87,7 @@ export const symbols: { [key: string]: Symbol } = {
icon: ShoppingBasket,
iconSvg: ShoppingBasketSvg,
},
crossing: {
value: 'Crossing',
icon: X,
iconSvg: XSvg,
},
crossing: { value: 'Crossing' },
department_store: {
value: 'Department Store',
icon: ShoppingBasket,
@@ -64,9 +64,3 @@
</svelte:head>
<div id="docsearch" class={props.class ?? ''}></div>
<style>
#docsearch :global(button) {
margin-left: 0px;
}
</style>
@@ -26,7 +26,7 @@
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button {...props} {variant} class="bg-inherit {className}" {onclick}>
<Button {...props} {variant} class={className} {onclick}>
{@render children()}
</Button>
{/snippet}
+111 -105
View File
@@ -1,118 +1,124 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
import Logo from '$lib/components/Logo.svelte';
import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils';
</script>
<footer class="w-full px-12 py-10 border-t flex flex-col items-center">
<div class="w-full max-w-5xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="grow flex flex-col items-start">
<Logo class="h-8" width="153" />
<Button
variant="link"
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 © 2026 gpx.studio
</Button>
<div class="mt-3 flex flex-row gap-1.5">
<LanguageSelect />
<ModeSwitch />
<footer class="w-full">
<div class="mx-6 border-t">
<div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="grow flex flex-col items-start">
<Logo class="h-8" width="153" />
<Button
variant="link"
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 © 2025 gpx.studio
</Button>
<LanguageSelect class="w-40 mt-3" />
</div>
</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">{i18n._('homepage.website')}</span>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/')}
>
<House size="16" />
{i18n._('homepage.home')}
</Button>
<Button
data-sveltekit-reload
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/app')}
>
<Map size="16" />
{i18n._('homepage.app')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/help')}
>
<BookOpenText size="16" />
{i18n._('menu.help')}
</Button>
</div>
<div class="flex flex-col items-start gap-1" id="contact">
<span class="font-semibold">{i18n._('homepage.contact')}</span>
<Button
variant="link"
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 fill-muted-foreground" />
{i18n._('homepage.reddit')}
</Button>
<Button
variant="link"
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 fill-muted-foreground" />
{i18n._('homepage.facebook')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="mailto:hello@gpx.studio"
target="_blank"
>
<AtSign size="16" />
{i18n._('homepage.email')}
</Button>
</div>
<div class="flex flex-col items-start gap-1">
<span class="font-semibold">{i18n._('homepage.contribute')}</span>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://opencollective.com/gpxstudio"
target="_blank"
>
<Heart size="16" />
{i18n._('menu.donate')}
</Button>
<Button
variant="link"
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 fill-muted-foreground" />
{i18n._('homepage.crowdin')}
</Button>
<Button
variant="link"
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 fill-muted-foreground" />
{i18n._('homepage.github')}
</Button>
<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">{i18n._('homepage.website')}</span>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/')}
>
<House size="16" />
{i18n._('homepage.home')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/app')}
>
<Map size="16" />
{i18n._('homepage.app')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/help')}
>
<BookOpenText size="16" />
{i18n._('menu.help')}
</Button>
</div>
<div class="flex flex-col items-start gap-1" id="contact">
<span class="font-semibold">{i18n._('homepage.contact')}</span>
<Button
variant="link"
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 fill-muted-foreground" />
{i18n._('homepage.reddit')}
</Button>
<Button
variant="link"
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 fill-muted-foreground" />
{i18n._('homepage.facebook')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://x.com/gpxstudio"
target="_blank"
>
<Logo company="x" class="h-4 fill-muted-foreground" />
{i18n._('homepage.x')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="mailto:hello@gpx.studio"
target="_blank"
>
<AtSign size="16" />
{i18n._('homepage.email')}
</Button>
</div>
<div class="flex flex-col items-start gap-1">
<span class="font-semibold">{i18n._('homepage.contribute')}</span>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://ko-fi.com/gpxstudio"
target="_blank"
>
<Heart size="16" />
{i18n._('menu.donate')}
</Button>
<Button
variant="link"
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 fill-muted-foreground" />
{i18n._('homepage.crowdin')}
</Button>
<Button
variant="link"
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 fill-muted-foreground" />
{i18n._('homepage.github')}
</Button>
</div>
</div>
</div>
</div>
+57 -56
View File
@@ -6,87 +6,88 @@
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import type { GPXStatistics } from 'gpx';
import type { Readable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
const { velocityUnits } = settings;
let panelHeight: number = $state(0);
let panelWidth: number = $state(0);
let {
gpxStatistics,
slicedGPXStatistics,
orientation,
panelSize,
}: {
gpxStatistics: Readable<GPXStatisticsGroup>;
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
gpxStatistics: Readable<GPXStatistics>;
slicedGPXStatistics: Readable<[GPXStatistics, number, number] | undefined>;
orientation: 'horizontal' | 'vertical';
panelSize: number;
} = $props();
let statistics = $derived(
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics.global
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics
);
</script>
<Card.Root
class="h-full {orientation === 'vertical'
? 'min-w-40 sm:min-w-44'
: 'w-full h-fit my-1'} ring-0 p-0 text-sm sm:text-base bg-transparent"
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
: 'w-full'} border-none shadow-none p-0"
>
<Card.Content class="h-full p-0">
<div
bind:clientHeight={panelHeight}
bind:clientWidth={panelWidth}
class="flex {orientation === 'vertical'
? 'flex-col h-full justify-center'
: 'flex-row w-full justify-evenly'} gap-4"
>
<Tooltip label={i18n._('quantities.distance')}>
<Card.Content
class="h-full flex {orientation === 'vertical'
? 'flex-col justify-center'
: 'flex-row w-full justify-between'} gap-4 p-0"
>
<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={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" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
</span>
</Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
label="{$velocityUnits === 'speed'
? i18n._('quantities.speed')
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" />
<WithUnits value={statistics.distance.total} type="distance" />
<Zap size="16" class="mr-1" />
<WithUnits
value={statistics.global.speed.moving}
type="speed"
showUnits={false}
/>
<span class="mx-1">/</span>
<WithUnits value={statistics.global.speed.total} type="speed" />
</span>
</Tooltip>
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
{/if}
{#if panelSize > 160 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.elevation.gain} type="elevation" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.elevation.loss} type="elevation" />
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.global.time.moving} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.global.time.total} type="time" />
</span>
</Tooltip>
{#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)}
<Tooltip
label="{$velocityUnits === 'speed'
? 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" />
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
<span class="mx-1">/</span>
<WithUnits value={statistics.speed.total} type="speed" />
</span>
</Tooltip>
{/if}
{#if panelHeight > 150 || (orientation === 'horizontal' && panelWidth > 620)}
<Tooltip
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.time.moving} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.time.total} type="time" />
</span>
</Tooltip>
{/if}
</div>
{/if}
</Card.Content>
</Card.Root>
+2 -2
View File
@@ -14,12 +14,12 @@
} = $props();
</script>
<div class="text-[13px] bg-secondary rounded border flex flex-row items-center p-2 {className}">
<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>
{@render children()}
{#if link}
<a href={link} target="_blank" class="text-[13px] text-link hover:underline">
<a href={link} target="_blank" class="text-sm text-link hover:underline">
{i18n._('menu.more')}
</a>
{/if}
@@ -5,10 +5,16 @@
import { getURLForLanguage } from '$lib/utils';
import { Languages } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
let {
class: className = '',
}: {
class?: string;
} = $props();
</script>
<Select.Root type="single" value={i18n.lang}>
<Select.Trigger class="w-[180px] px-2" aria-label={i18n._('menu.language')}>
<Select.Trigger class="min-w-[180px] {className}" aria-label={i18n._('menu.language')}>
<Languages size="16" />
<span class="mr-auto">
{languages[i18n.lang]}
+17 -1
View File
@@ -8,7 +8,7 @@
...others
}: {
iconOnly?: boolean;
company?: 'gpx.studio' | 'github' | 'crowdin' | 'facebook' | 'reddit';
company?: 'gpx.studio' | 'mapbox' | 'github' | 'crowdin' | 'facebook' | 'x' | 'reddit';
[key: string]: any;
} = $props();
</script>
@@ -19,6 +19,12 @@
alt="Logo of gpx.studio."
{...others}
/>
{:else if company === 'mapbox'}
<img
src="{base}/mapbox-logo-{mode.current === 'dark' ? 'white' : 'black'}.svg"
alt="Logo of Mapbox."
{...others}
/>
{:else if company === 'github'}
<svg
role="img"
@@ -49,6 +55,16 @@
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
>
{:else if company === 'x'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {others.class ?? ''}"
><title>X</title><path
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
/></svg
>
{:else if company === 'reddit'}
<svg
role="img"
+13 -56
View File
@@ -43,8 +43,6 @@
BookOpenText,
ChartArea,
Maximize,
Maximize2,
Minimize2,
} from '@lucide/svelte';
import { map } from '$lib/components/map/map';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
@@ -72,7 +70,7 @@
import { copied, selection } from '$lib/logic/selection';
import { allHidden } from '$lib/logic/hidden';
import { boundsManager } from '$lib/logic/bounds';
import { tick, onMount } from 'svelte';
import { tick } from 'svelte';
import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
const {
@@ -107,23 +105,6 @@
}
let layerSettingsOpen = $state(false);
let fullscreen = $state(false);
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
}
onMount(() => {
const handler = () => {
fullscreen = document.fullscreenElement !== null;
};
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
});
</script>
<div class="absolute md:top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none">
@@ -394,18 +375,8 @@
<Menubar.Item inset onclick={() => map.toggle3D()}>
<Box size="16" />
{i18n._('menu.toggle_3d')}
<Shortcut key={i18n._('menu.right_click_drag')} />
<Shortcut key="{i18n._('menu.ctrl')} {i18n._('menu.drag')}" />
</Menubar.Item>
<Menubar.Separator />
<Menubar.CheckboxItem checked={fullscreen} onCheckedChange={toggleFullscreen}>
{#if fullscreen}
<Minimize2 size="16" />
{:else}
<Maximize2 size="16" />
{/if}
{i18n._('menu.fullscreen')}
<Shortcut key="F11" />
</Menubar.CheckboxItem>
</Menubar.Content>
</Menubar.Menu>
<Menubar.Menu>
@@ -418,7 +389,7 @@
<Menubar.Content class="border-none">
<Menubar.Sub>
<Menubar.SubTrigger>
<Ruler size="16" />{i18n._('menu.distance_units')}
<Ruler size="16" class="mr-2" />{i18n._('menu.distance_units')}
</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.RadioGroup bind:value={$distanceUnits}>
@@ -436,7 +407,7 @@
</Menubar.Sub>
<Menubar.Sub>
<Menubar.SubTrigger>
<Zap size="16" />{i18n._('menu.velocity_units')}
<Zap size="16" class="mr-2" />{i18n._('menu.velocity_units')}
</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.RadioGroup bind:value={$velocityUnits}>
@@ -451,7 +422,7 @@
</Menubar.Sub>
<Menubar.Sub>
<Menubar.SubTrigger>
<Thermometer size="16" />{i18n._('menu.temperature_units')}
<Thermometer size="16" class="mr-2" />{i18n._('menu.temperature_units')}
</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.RadioGroup bind:value={$temperatureUnits}>
@@ -467,7 +438,7 @@
<Menubar.Separator />
<Menubar.Sub>
<Menubar.SubTrigger>
<Languages size="16" />
<Languages size="16" class="mr-2" />
{i18n._('menu.language')}
</Menubar.SubTrigger>
<Menubar.SubContent>
@@ -483,9 +454,9 @@
<Menubar.Sub>
<Menubar.SubTrigger>
{#if mode.current === 'light' || !mode.current}
<Sun size="16" />
<Sun size="16" class="mr-2" />
{:else}
<Moon size="16" />
<Moon size="16" class="mr-2" />
{/if}
{i18n._('menu.mode')}
</Menubar.SubTrigger>
@@ -508,7 +479,7 @@
<Menubar.Separator />
<Menubar.Sub>
<Menubar.SubTrigger>
<PersonStanding size="16" />
<PersonStanding size="16" class="mr-2" />
{i18n._('menu.street_view_source')}
</Menubar.SubTrigger>
<Menubar.SubContent>
@@ -529,12 +500,12 @@
</Menubar.Content>
</Menubar.Menu>
</Menubar.Root>
<div class="h-fit flex flex-row items-center">
<div class="h-fit flex flex-row items-center ml-1 gap-1">
<Button
variant="ghost"
href="./help"
target="_blank"
class="cursor-default h-fit rounded-md px-3 py-0.5"
class="cursor-default h-fit rounded-sm px-3 py-0.5"
aria-label={i18n._('menu.help')}
>
<BookOpenText size="18" class="md:hidden" />
@@ -544,9 +515,9 @@
</Button>
<Button
variant="ghost"
href="https://opencollective.com/gpxstudio"
href="https://ko-fi.com/gpxstudio"
target="_blank"
class="cursor-default h-fit rounded-md font-bold text-support hover:text-support px-3 py-0.5"
class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5"
aria-label={i18n._('menu.donate')}
>
<HeartHandshake size="18" class="md:hidden" />
@@ -567,7 +538,6 @@
let targetInput =
e &&
e.target &&
e.target instanceof HTMLElement &&
(e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.tagName === 'SELECT' ||
@@ -674,19 +644,6 @@
} 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) {
selection.updateFromKey(
e.key === 'ArrowRight' || e.key === 'ArrowDown',
e.shiftKey
);
e.preventDefault();
}
}
}}
on:dragover={(e) => e.preventDefault()}
+1 -1
View File
@@ -12,7 +12,7 @@
</script>
<Button
variant="outline"
variant="ghost"
size="icon"
class={className}
onclick={() => {
+10 -10
View File
@@ -1,30 +1,28 @@
<script lang="ts">
import Logo from '$lib/components/Logo.svelte';
import { Button } from '$lib/components/ui/button';
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
import { BookOpenText, House, Map } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils';
</script>
<nav class="sticky top-0 w-full px-12 py-2 bg-background z-50 flex flex-col items-center border-b">
<div class="w-full max-w-5xl flex flex-row items-center gap-4 sm:gap-8">
<a
href={getURLForLanguage(i18n.lang, '/')}
class="shrink-0 translate-y-0.25 justify-self-start"
>
<Logo class="h-8 xs:hidden" iconOnly={true} width="26" />
<Logo class="h-8 hidden xs:block" width="153" />
<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(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 has-[>svg]:px-0 ml-auto"
class="text-base px-0 has-[>svg]:px-0"
href={getURLForLanguage(i18n.lang, '/')}
>
<House size="18" />
{i18n._('homepage.home')}
</Button>
<Button
data-sveltekit-reload
variant="link"
class="text-base px-0 has-[>svg]:px-0"
href={getURLForLanguage(i18n.lang, '/app')}
@@ -40,5 +38,7 @@
<BookOpenText size="18" />
{i18n._('menu.help')}
</Button>
<AlgoliaDocSearch class="ml-auto" />
<ModeSwitch class="hidden xs:inline-flex" />
</div>
</nav>
@@ -35,7 +35,7 @@
<Button
variant="ghost"
size="icon"
class="w-full flex flex-row gap-1 border-none {side === 'right'
class="w-full flex flex-row gap-1 {side === 'right'
? 'justify-between'
: 'justify-start pl-1'} h-fit {nohover
? 'hover:bg-background'
@@ -62,7 +62,7 @@
<Button
variant="ghost"
size="icon"
class="w-full flex flex-row gap-1 border-none {side === 'right'
class="w-full flex flex-row gap-1 {side === 'right'
? 'justify-between'
: 'justify-start pl-1'} h-fit {nohover ? 'hover:bg-background' : ''}"
>
@@ -19,7 +19,7 @@
@apply text-foreground;
@apply text-3xl;
@apply font-semibold;
@apply mb-3;
@apply mb-3 pt-6;
}
:global(.markdown h2) {
@@ -12,7 +12,7 @@
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
{#if src === 'getting-started/interface'}
<enhanced:img
src="/src/lib/assets/img/docs/getting-started/interface.webp"
src="/src/lib/assets/img/docs/getting-started/interface.png"
{alt}
class="w-full max-w-3xl"
/>
@@ -20,13 +20,13 @@
<enhanced:img
src="/src/lib/assets/img/docs/tools/routing.png"
{alt}
class="w-full max-w-lg"
class="w-full max-w-3xl"
/>
{:else if src === 'tools/split'}
<enhanced:img
src="/src/lib/assets/img/docs/tools/split.png"
{alt}
class="w-full max-w-lg"
class="w-full max-w-3xl"
/>
{/if}
</div>
@@ -1,10 +1,10 @@
<script lang="ts">
import topoMap from '$lib/assets/img/docs/maps/topo.png?enhanced';
import waymarkedMap from '$lib/assets/img/docs/maps/waymarked.png?enhanced';
import mapboxOutdoorsMap from '$lib/assets/img/home/mapbox-outdoors.png?enhanced';
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
</script>
<div class="relative h-80 aspect-square rounded-2xl shadow-xl overflow-clip">
<enhanced:img src={topoMap} alt="Topo map screenshot." class="absolute" />
<enhanced:img src={mapboxOutdoorsMap} alt="Mapbox Outdoors map screenshot." class="absolute" />
<enhanced:img
src={waymarkedMap}
alt="Waymarked Trails map screenshot."
@@ -18,7 +18,7 @@
Construction,
} from '@lucide/svelte';
import type { Readable, Writable } from 'svelte/store';
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
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';
@@ -28,14 +28,12 @@
let {
gpxStatistics,
slicedGPXStatistics,
hoveredPoint,
additionalDatasets,
elevationFill,
showControls = true,
}: {
gpxStatistics: Readable<GPXStatisticsGroup>;
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
hoveredPoint: Writable<Coordinates | null>;
gpxStatistics: Readable<GPXStatistics>;
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
additionalDatasets: Writable<string[]>;
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
showControls?: boolean;
@@ -49,7 +47,6 @@
elevationProfile = new ElevationProfile(
gpxStatistics,
slicedGPXStatistics,
hoveredPoint,
additionalDatasets,
elevationFill,
canvas,
@@ -64,35 +61,37 @@
});
</script>
<div class="h-full grow min-w-0 min-h-0 relative">
<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-9 right-2.5">
<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 bg-background"
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 gap-0 overflow-hidden"
class="w-fit p-0 flex flex-col"
side="top"
align="end"
sideOffset={-32}
>
<ToggleGroup.Root
class="flex flex-col w-full border-none"
class="flex flex-col items-start gap-0 p-1 w-full border-none"
type="single"
size="sm"
bind:value={$elevationFill}
>
<ToggleGroup.Item value="slope" class="w-full flex flex-row justify-start">
<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" />
@@ -102,8 +101,9 @@
{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"
class="w-full flex flex-row justify-start"
variant="outline"
>
<div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'surface'}
@@ -114,8 +114,9 @@
{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"
class="w-full flex flex-row justify-start"
variant="outline"
>
<div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'highway'}
@@ -128,12 +129,14 @@
</ToggleGroup.Root>
<Separator />
<ToggleGroup.Root
class="flex flex-col gap-0"
class="flex flex-col items-start gap-0 p-1"
type="multiple"
size="sm"
bind:value={$additionalDatasets}
>
<ToggleGroup.Item value="speed" class="w-full flex flex-row justify-start">
<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" />
@@ -144,7 +147,10 @@
? i18n._('quantities.speed')
: i18n._('quantities.pace')}
</ToggleGroup.Item>
<ToggleGroup.Item value="hr" class="w-full flex flex-row justify-start">
<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" />
@@ -153,7 +159,10 @@
<HeartPulse size="15" />
{i18n._('quantities.heartrate')}
</ToggleGroup.Item>
<ToggleGroup.Item value="cad" class="w-full flex flex-row justify-start">
<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" />
@@ -162,7 +171,10 @@
<Orbit size="15" />
{i18n._('quantities.cadence')}
</ToggleGroup.Item>
<ToggleGroup.Item value="atemp" class="w-full flex flex-row justify-start">
<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" />
@@ -171,7 +183,10 @@
<Thermometer size="15" />
{i18n._('quantities.temperature')}
</ToggleGroup.Item>
<ToggleGroup.Item value="power" class="w-full flex flex-row justify-start">
<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" />
@@ -14,14 +14,11 @@ import {
getTemperatureWithUnits,
getVelocityWithUnits,
} from '$lib/units';
import Chart, {
type ChartEvent,
type ChartOptions,
type ScriptableLineSegmentContext,
type TooltipItem,
} from 'chart.js/auto';
import Chart from 'chart.js/auto';
import mapboxgl from 'mapbox-gl';
import { get, type Readable, type Writable } from 'svelte/store';
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
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';
@@ -30,37 +27,22 @@ 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
interface ElevationProfilePoint {
x: number;
y: number;
time?: Date;
slope: {
at: number;
segment: number;
length: number;
};
extensions: Record<string, any>;
coordinates: Coordinates;
index: number;
}
export class ElevationProfile {
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<GPXStatisticsGroup>;
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
private _hoveredPoint: Writable<Coordinates | null>;
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<GPXStatisticsGroup>,
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
hoveredPoint: Writable<Coordinates | null>,
gpxStatistics: Readable<GPXStatistics>,
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>,
additionalDatasets: Readable<string[]>,
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
canvas: HTMLCanvasElement,
@@ -68,12 +50,17 @@ export class ElevationProfile {
) {
this._gpxStatistics = gpxStatistics;
this._slicedGPXStatistics = slicedGPXStatistics;
this._hoveredPoint = hoveredPoint;
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();
@@ -103,7 +90,7 @@ export class ElevationProfile {
}
initialize() {
let options: ChartOptions<'line'> = {
let options = {
animation: false,
parsing: false,
maintainAspectRatio: false,
@@ -111,8 +98,8 @@ export class ElevationProfile {
x: {
type: 'linear',
ticks: {
callback: function (value: number | string) {
return `${(value as number).toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
callback: function (value: number) {
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
},
align: 'inner',
maxRotation: 0,
@@ -121,8 +108,8 @@ export class ElevationProfile {
y: {
type: 'linear',
ticks: {
callback: function (value: number | string) {
return getElevationWithUnits(value as number, false);
callback: function (value: number) {
return getElevationWithUnits(value, false);
},
},
},
@@ -153,13 +140,17 @@ export class ElevationProfile {
title: () => {
return '';
},
label: (context: TooltipItem<'line'>) => {
let point = context.raw as ElevationProfilePoint;
label: (context: Chart.TooltipContext) => {
let point = context.raw;
if (context.datasetIndex === 0) {
if (this._dragging) {
this._hoveredPoint.set(null);
} else {
this._hoveredPoint.set(point.coordinates);
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) {
@@ -174,10 +165,10 @@ export class ElevationProfile {
return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`;
}
},
afterBody: (contexts: TooltipItem<'line'>[]) => {
afterBody: (contexts: Chart.TooltipContext[]) => {
let context = contexts.filter((context) => context.datasetIndex === 0);
if (context.length === 0) return;
let point = context[0].raw as ElevationProfilePoint;
let point = context[0].raw;
let slope = {
at: point.slope.at.toFixed(1),
segment: point.slope.segment.toFixed(1),
@@ -236,7 +227,6 @@ export class ElevationProfile {
onPanStart: () => {
this._panning = true;
this._slicedGPXStatistics.set(undefined);
return true;
},
onPanComplete: () => {
this._panning = false;
@@ -248,13 +238,13 @@ export class ElevationProfile {
},
mode: 'x',
onZoomStart: ({ chart, event }: { chart: Chart; event: any }) => {
if (!this._chart) {
return false;
}
const maxZoom = this._chart.getInitialScaleBounds()?.x?.max ?? 0;
if (
event.deltaY < 0 &&
Math.abs(maxZoom / this._chart.getZoomLevel()) < 0.01
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;
@@ -272,6 +262,7 @@ export class ElevationProfile {
},
},
},
stacked: false,
onResize: () => {
this.updateOverlay();
},
@@ -279,7 +270,7 @@ export class ElevationProfile {
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
datasets.forEach((id) => {
options.scales![`y${id}`] = {
options.scales[`y${id}`] = {
type: 'linear',
position: 'right',
grid: {
@@ -300,9 +291,12 @@ export class ElevationProfile {
{
id: 'toggleMarker',
events: ['mouseout'],
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
afterEvent: (chart: Chart, args: { event: Chart.ChartEvent }) => {
if (args.event.type === 'mouseout') {
this._hoveredPoint.set(null);
const map_ = get(map);
if (map_ && this._marker) {
this._marker.remove();
}
}
},
},
@@ -311,7 +305,7 @@ export class ElevationProfile {
let startIndex = 0;
let endIndex = 0;
const getIndex = (evt: PointerEvent) => {
const getIndex = (evt) => {
if (!this._chart) {
return undefined;
}
@@ -329,22 +323,22 @@ export class ElevationProfile {
if (evt.x - rect.left <= this._chart.chartArea.left) {
return 0;
} else if (evt.x - rect.left >= this._chart.chartArea.right) {
return this._chart.data.datasets[0].data.length - 1;
return get(this._gpxStatistics).local.points.length - 1;
} else {
return undefined;
}
}
const point = points.find((point) => (point.element as any).raw);
let point = points.find((point) => point.element.raw);
if (point) {
return (point.element as any).raw.index;
return point.element.raw.index;
} else {
return points[0].index;
}
};
let dragStarted = false;
const onMouseDown = (evt: PointerEvent) => {
const onMouseDown = (evt) => {
if (evt.shiftKey) {
// Panning interaction
return;
@@ -353,7 +347,7 @@ export class ElevationProfile {
this._canvas.style.cursor = 'col-resize';
startIndex = getIndex(evt);
};
const onMouseMove = (evt: PointerEvent) => {
const onMouseMove = (evt) => {
if (dragStarted) {
this._dragging = true;
endIndex = getIndex(evt);
@@ -362,7 +356,7 @@ export class ElevationProfile {
startIndex = endIndex;
} else if (startIndex !== endIndex) {
this._slicedGPXStatistics.set([
get(this._gpxStatistics).sliced(
get(this._gpxStatistics).slice(
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex)
),
@@ -373,7 +367,7 @@ export class ElevationProfile {
}
}
};
const onMouseUp = (evt: PointerEvent) => {
const onMouseUp = (evt) => {
dragStarted = false;
this._dragging = false;
this._canvas.style.cursor = '';
@@ -392,99 +386,85 @@ export class ElevationProfile {
return;
}
const data = get(this._gpxStatistics);
const units = {
distance: get(distanceUnits),
velocity: get(velocityUnits),
temperature: get(temperatureUnits),
};
const datasets: Array<Array<any>> = [[], [], [], [], [], []];
data.forEachTrackPoint((trkpt, distance, speed, slope, index) => {
datasets[0].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.ele ? getConvertedElevation(trkpt.ele, units.distance) : 0,
time: trkpt.time,
slope: slope,
extensions: trkpt.getExtensions(),
coordinates: trkpt.getCoordinates(),
index: index,
});
if (data.global.time.total > 0) {
datasets[1].push({
x: getConvertedDistance(distance, units.distance),
y: getConvertedVelocity(speed, units.velocity, units.distance),
index: index,
});
}
if (data.global.hr.count > 0) {
datasets[2].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getHeartRate(),
index: index,
});
}
if (data.global.cad.count > 0) {
datasets[3].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getCadence(),
index: index,
});
}
if (data.global.atemp.count > 0) {
datasets[4].push({
x: getConvertedDistance(distance, units.distance),
y: getConvertedTemperature(trkpt.getTemperature(), units.temperature),
index: index,
});
}
if (data.global.power.count > 0) {
datasets[5].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getPower(),
index: index,
});
}
});
this._chart.data.datasets[0] = {
label: i18n._('quantities.elevation'),
data: datasets[0],
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: 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: 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: 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: 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: 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,
units.distance
);
this._chart.options.scales.x['min'] = 0;
this._chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
this.setVisibility();
this.setFill();
@@ -533,24 +513,21 @@ export class ElevationProfile {
return;
}
const elevationFill = get(this._elevationFill);
const dataset = this._chart.data.datasets[0];
let segment: any = {};
if (elevationFill === 'slope') {
segment = {
this._chart.data.datasets[0]['segment'] = {
backgroundColor: this.slopeFillCallback,
};
} else if (elevationFill === 'surface') {
segment = {
this._chart.data.datasets[0]['segment'] = {
backgroundColor: this.surfaceFillCallback,
};
} else if (elevationFill === 'highway') {
segment = {
this._chart.data.datasets[0]['segment'] = {
backgroundColor: this.highwayFillCallback,
};
} else {
segment = {};
this._chart.data.datasets[0]['segment'] = {};
}
Object.assign(dataset, { segment });
}
updateOverlay() {
@@ -577,12 +554,10 @@ export class ElevationProfile {
const gpxStatistics = get(this._gpxStatistics);
let startPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(
gpxStatistics.getTrackPoint(startIndex)?.distance.total ?? 0
)
getConvertedDistance(gpxStatistics.local.distance.total[startIndex])
);
let endPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(gpxStatistics.getTrackPoint(endIndex)?.distance.total ?? 0)
getConvertedDistance(gpxStatistics.local.distance.total[endIndex])
);
selectionContext.fillRect(
@@ -600,22 +575,19 @@ export class ElevationProfile {
}
}
slopeFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
const point = context.p0.raw as ElevationProfilePoint;
return getSlopeColor(point.slope.segment);
slopeFillCallback(context) {
return getSlopeColor(context.p0.raw.slope.segment);
}
surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
const point = context.p0.raw as ElevationProfilePoint;
return getSurfaceColor(point.extensions.surface);
surfaceFillCallback(context) {
return getSurfaceColor(context.p0.raw.extensions.surface);
}
highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
const point = context.p0.raw as ElevationProfilePoint;
highwayFillCallback(context) {
return getHighwayColor(
point.extensions.highway,
point.extensions.sac_scale,
point.extensions.mtb_scale
context.p0.raw.extensions.highway,
context.p0.raw.extensions.sac_scale,
context.p0.raw.extensions.mtb_scale
);
}
@@ -624,5 +596,8 @@ export class ElevationProfile {
this._chart.destroy();
this._chart = null;
}
if (this._marker) {
this._marker.remove();
}
}
}
@@ -16,11 +16,10 @@
import { setMode } from 'mode-watcher';
import { settings } from '$lib/logic/settings';
import { fileStateCollection } from '$lib/logic/file-state';
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
import { loadFile } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection';
import { untrack } from 'svelte';
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
let {
useHash = true,
@@ -33,7 +32,6 @@
const {
currentBasemap,
selectedBasemapTree,
distanceUnits,
velocityUnits,
temperatureUnits,
@@ -68,9 +66,6 @@
if (allowedEmbeddingBasemaps.includes(options.basemap)) {
$currentBasemap = options.basemap;
}
if (!isSelected($selectedBasemapTree, options.basemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, options.basemap);
}
$distanceMarkers = options.distanceMarkers;
$directionMarkers = options.directionMarkers;
$distanceUnits = options.distanceUnits;
@@ -102,7 +97,7 @@
<div class="grow relative">
<Map
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
maptilerKey={options.key}
accessToken={options.token}
geocoder={false}
geolocate={true}
hash={useHash}
@@ -117,19 +112,19 @@
{/if}
</div>
<div
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 p-2 sm:px-4"
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 px-2 sm:px-4"
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
>
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
panelSize={options.elevation.height}
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
/>
{#if options.elevation.show}
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
showControls={options.elevation.controls}
@@ -22,7 +22,7 @@
getCleanedEmbeddingOptions,
getMergedEmbeddingOptions,
} from './embedding';
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import Embedding from './Embedding.svelte';
import { onDestroy } from 'svelte';
import { base } from '$app/paths';
@@ -32,7 +32,7 @@
let options = $state(
getMergedEmbeddingOptions(
{
key: 'YOUR_MAPTILER_KEY',
token: 'YOUR_MAPBOX_TOKEN',
theme: mode.current,
},
defaultEmbeddingOptions
@@ -46,10 +46,10 @@
let iframeOptions = $derived(
getMergedEmbeddingOptions(
{
key:
options.key.length === 0 || options.key === 'YOUR_MAPTILER_KEY'
? PUBLIC_MAPTILER_KEY
: options.key,
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: {
@@ -102,8 +102,8 @@
</Card.Header>
<Card.Content>
<fieldset class="flex flex-col gap-3">
<Label for="key">{i18n._('embedding.maptiler_key')}</Label>
<Input id="key" type="text" class="h-8" bind:value={options.key} />
<Label for="token">{i18n._('embedding.mapbox_token')}</Label>
<Input id="token" type="text" class="h-8" bind:value={options.token} />
<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">{i18n._('embedding.drive_ids')}</Label>
@@ -1,8 +1,8 @@
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { basemaps } from '$lib/assets/layers';
export type EmbeddingOptions = {
key: string;
token: string;
files: string[];
ids: string[];
basemap: string;
@@ -26,10 +26,10 @@ export type EmbeddingOptions = {
};
export const defaultEmbeddingOptions = {
key: '',
token: '',
files: [],
ids: [],
basemap: 'topo',
basemap: 'mapboxOutdoors',
elevation: {
show: true,
height: 170,
@@ -90,9 +90,6 @@ export function getCleanedEmbeddingOptions(
delete cleanedOptions[key];
}
}
if (cleanedOptions['key'] && cleanedOptions['key'] === PUBLIC_MAPTILER_KEY) {
delete cleanedOptions['key'];
}
return cleanedOptions;
}
@@ -110,7 +107,7 @@ export function getURLForGoogleDriveFile(fileId: string): string {
export function convertOldEmbeddingOptions(options: URLSearchParams): any {
let newOptions: any = {
key: PUBLIC_MAPTILER_KEY,
token: PUBLIC_MAPBOX_TOKEN,
files: [],
ids: [],
};
@@ -125,7 +122,9 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
}
if (options.has('source')) {
let basemap = options.get('source')!;
if (basemap === 'otm') {
if (basemap === 'satellite') {
newOptions.basemap = 'mapboxSatellite';
} else if (basemap === 'otm') {
newOptions.basemap = 'openTopoMap';
} else if (basemap === 'ohm') {
newOptions.basemap = 'openHikingMap';
+11 -15
View File
@@ -21,7 +21,7 @@
SquareActivity,
} from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { GPXGlobalStatistics } from 'gpx';
import { GPXStatistics } from 'gpx';
import { ListRootItem } from '$lib/components/file-list/file-list';
import { fileStateCollection } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection';
@@ -48,24 +48,24 @@
extensions: false,
};
} else {
let statistics = $gpxStatistics.global;
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()).global);
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
}
return acc;
}, new GPXGlobalStatistics());
}, new GPXStatistics());
}
return {
time: statistics.time.total === 0,
hr: statistics.hr.count === 0,
cad: statistics.cad.count === 0,
atemp: statistics.atemp.count === 0,
power: statistics.power.count === 0,
extensions: Object.keys(statistics.extensions).length === 0,
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,
};
}
});
@@ -100,11 +100,7 @@
</span>
</div>
<div class="w-full flex flex-row flex-wrap gap-2">
<Button
class="bg-support grow"
href="https://opencollective.com/gpxstudio"
target="_blank"
>
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
{i18n._('menu.support_button')}
<span>🙏</span>
</Button>
@@ -121,16 +121,20 @@
}
.vertical :global(button) {
@apply hover:bg-[var(--selection)];
@apply hover:bg-muted;
}
.vertical :global(.sortable-selected button) {
@apply hover:bg-accent;
}
.vertical :global(.sortable-selected) {
@apply bg-[var(--selection)];
@apply bg-accent;
}
.horizontal :global(button) {
@apply bg-[var(--selection)];
@apply hover:bg-background;
@apply bg-accent;
@apply hover:bg-muted;
}
.horizontal :global(.sortable-selected button) {
@@ -34,10 +34,11 @@
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 { gpxColors, gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
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';
@@ -57,31 +58,41 @@
let singleSelection = $derived($selection.size === 1);
let nodeColors: string[] = $derived.by(() => {
let nodeColors: string[] = $state([]);
$effect.pre(() => {
let colors: string[] = [];
if (node) {
if (node && $map) {
if (node instanceof GPXFile) {
let defaultColor = $gpxColors.get(item.getFileId());
let defaultColor = undefined;
let layer = gpxLayers.getLayer(item.getFileId());
if (layer) {
defaultColor = layer.layerColor;
}
let style = node.getStyle(defaultColor);
colors = style.color;
style.color.forEach((c) => {
if (!colors.includes(c)) {
colors.push(c);
}
});
} else if (node instanceof Track) {
let style = node.getStyle();
if (
style &&
style['gpx_style:color'] &&
!colors.includes(style['gpx_style:color'])
) {
colors.push(style['gpx_style:color']);
if (style) {
if (style['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) {
colors.push(style['gpx_style:color']);
}
}
if (colors.length === 0) {
let defaultColor = $gpxColors.get(item.getFileId());
if (defaultColor) {
colors.push(defaultColor);
let layer = gpxLayers.getLayer(item.getFileId());
if (layer) {
colors.push(layer.layerColor);
}
}
}
}
return colors;
nodeColors = colors;
});
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
@@ -114,10 +125,10 @@
<ContextMenu.Trigger class="grow truncate">
<Button
variant="ghost"
class="relative w-full p-0 overflow-hidden border-none focus-visible:ring-0 focus-visible:ring-offset-0 flex flex-row {orientation ===
class="relative w-full p-0 overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
'vertical'
? 'h-7'
: 'h-9 px-1.5'} pointer-events-auto"
? 'h-fit'
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
@@ -126,7 +137,7 @@
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
<div
class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-0 w-1'
? 'top-0 bottom-0 right-1 w-1'
: 'top-0 h-1 left-0 right-0'}"
style="background:linear-gradient(to {orientation === 'vertical'
? 'bottom'
@@ -139,7 +150,7 @@
></div>
{/if}
<span
class="grow text-left truncate ml-1 flex flex-row items-center {hidden
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
? 'text-muted-foreground'
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
? 'text-muted-foreground'
@@ -164,7 +175,7 @@
let file = fileStateCollection.getFile(item.getFileId());
if (layer && file) {
let waypoint = file.wpt[item.getWaypointIndex()];
if (waypoint && !waypoint._data.hidden) {
if (waypoint) {
waypointPopup?.setItem({
item: waypoint,
fileId: item.getFileId(),
@@ -5,16 +5,6 @@
map.onLoad((map_) => {
map_.on('contextmenu', (e) => {
if (
map_.queryRenderedFeatures(e.point, {
layers: map_
.getLayersOrder()
.filter((layerId) => layerId.startsWith('routing-controls')),
}).length
) {
// Clicked on routing control, ignoring
return;
}
trackpointPopup?.setItem({
item: new TrackPoint({
attributes: {
+40 -43
View File
@@ -1,25 +1,30 @@
<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';
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
let {
maptilerKey = PUBLIC_MAPTILER_KEY,
accessToken = PUBLIC_MAPBOX_TOKEN,
geolocate = true,
geocoder = true,
hash = true,
class: className = '',
}: {
maptilerKey?: string;
accessToken?: string;
geolocate?: boolean;
geocoder?: boolean;
hash?: boolean;
class?: string;
} = $props();
mapboxgl.accessToken = accessToken;
let webgl2Supported = $state(true);
let embeddedApp = $state(false);
@@ -43,7 +48,7 @@
language = 'en';
}
map.init(maptilerKey, language, hash, geocoder, geolocate);
map.init(PUBLIC_MAPBOX_TOKEN, language, hash, geocoder, geolocate);
});
onDestroy(() => {
@@ -76,21 +81,21 @@
<style lang="postcss">
@reference "../../../app.css";
div :global(.maplibregl-map) {
div :global(.mapboxgl-map) {
@apply font-sans;
}
div :global(.maplibregl-ctrl-top-right > .maplibregl-ctrl) {
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
@apply shadow-md;
@apply bg-background;
@apply text-foreground;
}
div :global(.maplibregl-ctrl-icon) {
div :global(.mapboxgl-ctrl-icon) {
@apply dark:brightness-[4.7];
}
div :global(.maplibregl-ctrl-geocoder) {
div :global(.mapboxgl-ctrl-geocoder) {
@apply flex;
@apply flex-row;
@apply w-fit;
@@ -105,45 +110,36 @@
@apply text-foreground;
}
div :global(.maplibregl-ctrl-geocoder .suggestions > li > a) {
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
@apply text-foreground;
@apply hover:text-accent-foreground;
@apply hover:bg-accent;
}
div :global(.maplibregl-ctrl-geocoder .suggestions > .active > a) {
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
@apply bg-background;
}
div :global(.maplibregl-ctrl-geocoder--button) {
div :global(.mapboxgl-ctrl-geocoder--button) {
@apply bg-transparent;
@apply hover:bg-transparent;
}
div :global(.maplibregl-ctrl-geocoder--icon) {
div :global(.mapboxgl-ctrl-geocoder--icon) {
@apply fill-foreground;
@apply hover:fill-accent-foreground;
}
div :global(.maplibregl-ctrl-geocoder--icon-search) {
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
@apply relative;
@apply top-0;
@apply left-0;
@apply my-2;
@apply w-[29px];
}
div :global(.maplibregl-ctrl-geocoder--icon-loading) {
@apply -mt-1;
@apply mb-0;
}
div :global(.maplibregl-ctrl-geocoder--icon-close) {
@apply my-0;
}
div :global(.maplibregl-ctrl-geocoder--input) {
div :global(.mapboxgl-ctrl-geocoder--input) {
@apply relative;
@apply h-8;
@apply w-64;
@apply py-0;
@apply pl-2;
@@ -153,12 +149,12 @@
@apply text-foreground;
}
div :global(.maplibregl-ctrl-geocoder--collapsed .maplibregl-ctrl-geocoder--input) {
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
@apply w-0;
@apply p-0;
}
div :global(.maplibregl-ctrl-top-right) {
div :global(.mapboxgl-ctrl-top-right) {
@apply z-40;
@apply flex;
@apply flex-col;
@@ -167,76 +163,77 @@
@apply overflow-hidden;
}
.horizontal :global(.maplibregl-ctrl-bottom-left) {
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
@apply bottom-[42px];
}
.horizontal :global(.maplibregl-ctrl-bottom-right) {
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
@apply bottom-[42px];
}
div :global(.maplibregl-ctrl-attrib) {
div :global(.mapboxgl-ctrl-attrib) {
@apply dark:bg-transparent;
}
div :global(.maplibregl-compact-show.maplibregl-ctrl-attrib) {
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
@apply dark:bg-background;
}
div :global(.maplibregl-ctrl-attrib-button) {
div :global(.mapboxgl-ctrl-attrib-button) {
@apply dark:bg-foreground;
}
div :global(.maplibregl-compact-show .maplibregl-ctrl-attrib-button) {
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
@apply dark:bg-foreground;
}
div :global(.maplibregl-ctrl-attrib a) {
div :global(.mapboxgl-ctrl-attrib a) {
@apply text-foreground;
}
div :global(.maplibregl-popup) {
div :global(.mapboxgl-popup) {
@apply w-fit;
@apply z-50;
}
div :global(.maplibregl-popup-content) {
div :global(.mapboxgl-popup-content) {
@apply p-0;
@apply bg-transparent;
@apply shadow-none;
}
div :global(.maplibregl-popup-anchor-top .maplibregl-popup-tip) {
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.maplibregl-popup-anchor-top-left .maplibregl-popup-tip) {
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.maplibregl-popup-anchor-top-right .maplibregl-popup-tip) {
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.maplibregl-popup-anchor-bottom .maplibregl-popup-tip) {
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip) {
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip) {
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.maplibregl-popup-anchor-left .maplibregl-popup-tip) {
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
@apply border-r-background;
}
div :global(.maplibregl-popup-anchor-right .maplibregl-popup-tip) {
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
@apply border-l-background;
}
</style>
@@ -17,7 +17,7 @@
let control: CustomControl | null = null;
onMount(() => {
map.onLoad((map: maplibregl.Map) => {
map.onLoad((map: mapboxgl.Map) => {
if (position.includes('right')) container.classList.add('float-right');
else container.classList.add('float-left');
container.classList.remove('hidden');
@@ -1,4 +1,4 @@
import { type Map, type IControl } from 'maplibre-gl';
import { type Map, type IControl } from 'mapbox-gl';
export default class CustomControl implements IControl {
_map: Map | undefined;
@@ -16,7 +16,7 @@
</script>
<Button
class="justify-start {className}"
class="p-1 has-[>svg]:px-2 h-8 justify-start {className}"
variant="outline"
onclick={() => {
navigator.clipboard.writeText(
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy } from 'svelte';
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';
@@ -9,10 +9,13 @@
let distanceMarkers: DistanceMarkers;
let startEndMarkers: StartEndMarkers;
map.onLoad((map_) => {
onMount(() => {
gpxLayers.init();
startEndMarkers = new StartEndMarkers();
distanceMarkers = new DistanceMarkers();
});
map.onLoad((map_) => {
createPopups(map_);
});
@@ -1,13 +1,11 @@
<script lang="ts">
import type { TrackPoint } from 'gpx';
import { Button } from '$lib/components/ui/button';
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, Earth, Mountain, Timer } from '@lucide/svelte';
import { Compass, Mountain, Timer } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import type { PopupItem } from '$lib/components/map/map-popup';
import { map } from '$lib/components/map/map';
let { trackpoint }: { trackpoint: PopupItem<TrackPoint> } = $props();
</script>
@@ -37,16 +35,5 @@
onCopy={() => trackpoint.hide?.()}
class="mt-0.5"
/>
{#if trackpoint.fileId === undefined}
<Button
variant="outline"
class="justify-start"
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.getLatitude().toFixed(5)}/${trackpoint.item.getLongitude().toFixed(5)}`}
target="_blank"
>
<Earth size="14" />
{i18n._('menu.edit_osm')}
</Button>
{/if}
</Card.Content>
</Card.Root>
@@ -13,8 +13,6 @@
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';
import { selection } from '$lib/logic/selection';
import { ListFileItem } from '$lib/components/file-list/file-list';
let {
waypoint,
@@ -22,9 +20,6 @@
waypoint: PopupItem<Waypoint>;
} = $props();
let selected = $derived(
waypoint.fileId ? $selection.hasAnyChildren(new ListFileItem(waypoint.fileId)) : false
);
let symbolKey = $derived(waypoint ? getSymbolKey(waypoint.item.sym) : undefined);
function sanitize(text: string | undefined): string {
@@ -86,8 +81,9 @@
</ScrollArea>
<div class="mt-2 flex flex-col gap-1">
<CopyCoordinates coordinates={waypoint.item.attributes} />
{#if $currentTool === Tool.WAYPOINT && selected}
{#if $currentTool === Tool.WAYPOINT}
<Button
class="p-1 has-[>svg]:px-2 h-8"
variant="outline"
onclick={() => {
if (waypoint.fileId) {
@@ -1,15 +1,21 @@
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';
import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
const { distanceMarkers, distanceUnits } = settings;
const levels = [100, 50, 25, 10, 5, 1];
const stops = [
[100, 0],
[50, 7],
[25, 8, 10],
[10, 10],
[5, 11],
[1, 13],
];
export class DistanceMarkers {
updateBinded: () => void = this.update.bind(this);
@@ -23,7 +29,7 @@ export class DistanceMarkers {
this.unsubscribes.push(
map.subscribe((map_) => {
if (map_) {
map_.on('style.load', this.updateBinded);
map_.on('style.import.load', this.updateBinded);
}
})
);
@@ -44,33 +50,22 @@ export class DistanceMarkers {
data: this.getDistanceMarkersGeoJSON(),
});
}
if (!map_.getLayer('distance-markers')) {
map_.addLayer(
{
id: 'distance-markers',
stops.forEach(([d, minzoom, maxzoom]) => {
if (!map_.getLayer(`distance-markers-${d}`)) {
map_.addLayer({
id: `distance-markers-${d}`,
type: 'symbol',
source: 'distance-markers',
filter: [
'match',
['get', 'level'],
100,
['>=', ['zoom'], 0],
50,
['>=', ['zoom'], 7],
25,
[
'any',
['all', ['>=', ['zoom'], 8], ['<=', ['zoom'], 9]],
['>=', ['zoom'], 11],
],
10,
['>=', ['zoom'], 10],
5,
['>=', ['zoom'], 11],
1,
['>=', ['zoom'], 13],
false,
],
filter:
d === 5
? [
'any',
['==', ['get', 'level'], 5],
['==', ['get', 'level'], 25],
]
: ['==', ['get', 'level'], d],
minzoom: minzoom,
maxzoom: maxzoom ?? 24,
layout: {
'text-field': ['get', 'distance'],
'text-size': 14,
@@ -81,14 +76,17 @@ export class DistanceMarkers {
'text-halo-width': 2,
'text-halo-color': 'white',
},
},
ANCHOR_LAYER_KEY.distanceMarkers
);
}
});
} else {
map_.moveLayer(`distance-markers-${d}`);
}
});
} else {
if (map_.getLayer('distance-markers')) {
map_.removeLayer('distance-markers');
}
stops.forEach(([d]) => {
if (map_.getLayer(`distance-markers-${d}`)) {
map_.removeLayer(`distance-markers-${d}`);
}
});
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
@@ -103,26 +101,35 @@ export class DistanceMarkers {
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
let statistics = get(gpxStatistics);
let features: GeoJSON.Feature[] = [];
let features = [];
let currentTargetDistance = 1;
statistics.forEachTrackPoint((trkpt, dist) => {
if (dist >= getConvertedDistanceToKilometers(currentTargetDistance)) {
for (let i = 0; i < statistics.local.distance.total.length; i++) {
if (
statistics.local.distance.total[i] >=
getConvertedDistanceToKilometers(currentTargetDistance)
) {
let distance = currentTargetDistance.toFixed(0);
let level = levels.find((level) => currentTargetDistance % level === 0) || 1;
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [
0, 0,
];
features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [trkpt.getLongitude(), trkpt.getLatitude()],
coordinates: [
statistics.local.points[i].getLongitude(),
statistics.local.points[i].getLatitude(),
],
},
properties: {
distance,
level,
minzoom,
},
} as GeoJSON.Feature);
currentTargetDistance += 1;
}
});
}
return {
type: 'FeatureCollection',
@@ -3,14 +3,13 @@ import { MapPopup } from '$lib/components/map/map-popup';
export let waypointPopup: MapPopup | null = null;
export let trackpointPopup: MapPopup | null = null;
export function createPopups(map: maplibregl.Map) {
export function createPopups(map: mapboxgl.Map) {
removePopups();
waypointPopup = new MapPopup(map, {
closeButton: false,
focusAfterOpen: false,
maxWidth: undefined,
offset: {
center: [0, 0],
top: [0, 0],
'top-left': [0, 0],
'top-right': [0, 0],
@@ -1,10 +1,5 @@
import { get, type Readable } from 'svelte/store';
import maplibregl, {
type GeoJSONSource,
type FilterSpecification,
type MapLayerMouseEvent,
type MapLayerTouchEvent,
} from 'maplibre-gl';
import mapboxgl from 'mapbox-gl';
import { map } from '$lib/components/map/map';
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
import {
@@ -15,7 +10,7 @@ import {
ListFileItem,
ListRootItem,
} from '$lib/components/file-list/file-list';
import { getClosestLinePoint, getElevation, loadSVGIcon } from '$lib/utils';
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';
@@ -27,8 +22,6 @@ 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';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { gpxColors } from '$lib/components/map/gpx-layer/gpx-layers';
const colors = [
'#ff0000',
@@ -50,49 +43,26 @@ for (let color of colors) {
}
// Get the color with the least amount of uses
function getColor(fileId: string) {
function getColor() {
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
colorCount[color]++;
gpxColors.update((colors) => {
colors.set(fileId, color);
return colors;
});
return color;
}
function replaceColor(fileId: string, oldColor: string, newColor: string) {
if (colorCount.hasOwnProperty(oldColor)) {
colorCount[oldColor]--;
}
colorCount[newColor]++;
gpxColors.update((colors) => {
colors.set(fileId, newColor);
return colors;
});
}
function removeColor(fileId: string, color: string) {
function decrementColor(color: string) {
if (colorCount.hasOwnProperty(color)) {
colorCount[color]--;
}
gpxColors.update((colors) => {
colors.delete(fileId);
return colors;
});
}
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
${
layerColor
? Square.replace('width="24"', 'width="12"')
.replace('height="24"', 'height="12"')
.replace('stroke="currentColor"', 'stroke="SteelBlue"')
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
.replace('fill="none"', `fill="${layerColor}"`)
: ''
}
${Square.replace('width="24"', 'width="12"')
.replace('height="24"', 'height="12"')
.replace('stroke="currentColor"', 'stroke="SteelBlue"')
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
.replace('fill="none"', `fill="${layerColor}"`)}
${MapPin.replace('width="24"', '')
.replace('height="24"', '')
.replace('stroke="currentColor"', '')
@@ -117,41 +87,26 @@ export class GPXLayer {
fileId: string;
file: Readable<GPXFileWithStatistics | undefined>;
layerColor: string;
markers: mapboxgl.Marker[] = [];
selected: boolean = false;
currentWaypointData: GeoJSON.FeatureCollection | null = null;
draggedWaypointIndex: number | null = null;
draggingStartingPosition: maplibregl.Point = new maplibregl.Point(0, 0);
draggable: boolean;
unsubscribe: Function[] = [];
updateBinded: () => void = this.update.bind(this);
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
layerOnClickBinded: (e: MapLayerMouseEvent) => void = this.layerOnClick.bind(this);
layerOnContextMenuBinded: (e: MapLayerMouseEvent) => void = this.layerOnContextMenu.bind(this);
waypointLayerOnMouseEnterBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnMouseEnter.bind(this);
waypointLayerOnMouseLeaveBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnMouseLeave.bind(this);
waypointLayerOnClickBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnClick.bind(this);
waypointLayerOnMouseDownBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnMouseDown.bind(this);
waypointLayerOnTouchStartBinded: (e: MapLayerTouchEvent) => void =
this.waypointLayerOnTouchStart.bind(this);
waypointLayerOnMouseMoveBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
this.waypointLayerOnMouseMove.bind(this);
waypointLayerOnMouseUpBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
this.waypointLayerOnMouseUp.bind(this);
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
this.fileId = fileId;
this.file = file;
this.layerColor = getColor(fileId);
this.layerColor = getColor();
this.unsubscribe.push(
map.subscribe(($map) => {
if ($map) {
$map.on('style.load', this.updateBinded);
$map.on('style.import.load', this.updateBinded);
this.update();
}
})
@@ -170,13 +125,24 @@ export class GPXLayer {
})
);
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
this.unsubscribe.push(
currentTool.subscribe((tool) => {
if (tool === Tool.WAYPOINT && !this.draggable) {
this.draggable = true;
this.markers.forEach((marker) => marker.setDraggable(true));
} else if (tool !== Tool.WAYPOINT && this.draggable) {
this.draggable = false;
this.markers.forEach((marker) => marker.setDraggable(false));
}
})
);
this.draggable = get(currentTool) === Tool.WAYPOINT;
}
update() {
const _map = get(map);
const layerEventManager = map.layerEventManager;
let file = get(this.file)?.file;
if (!_map || !layerEventManager || !file) {
if (!_map || !file) {
return;
}
@@ -185,14 +151,12 @@ export class GPXLayer {
file._data.style.color &&
this.layerColor !== `#${file._data.style.color}`
) {
replaceColor(this.fileId, this.layerColor, `#${file._data.style.color}`);
decrementColor(this.layerColor);
this.layerColor = `#${file._data.style.color}`;
}
this.loadIcons();
try {
let source = _map.getSource(this.fileId) as GeoJSONSource | undefined;
let source = _map.getSource(this.fileId) as mapboxgl.GeoJSONSource | undefined;
if (source) {
source.setData(this.getGeoJSON());
} else {
@@ -203,45 +167,28 @@ export class GPXLayer {
}
if (!_map.getLayer(this.fileId)) {
_map.addLayer(
{
id: this.fileId,
type: 'line',
source: this.fileId,
layout: {
'line-join': 'round',
'line-cap': 'round',
},
paint: {
'line-color': ['get', 'color'],
'line-width': ['get', 'width'],
'line-opacity': ['get', 'opacity'],
},
_map.addLayer({
id: this.fileId,
type: 'line',
source: this.fileId,
layout: {
'line-join': 'round',
'line-cap': 'round',
},
ANCHOR_LAYER_KEY.tracks
);
paint: {
'line-color': ['get', 'color'],
'line-width': ['get', 'width'],
'line-opacity': ['get', 'opacity'],
},
});
layerEventManager.on('click', this.fileId, this.layerOnClickBinded);
layerEventManager.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
layerEventManager.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
layerEventManager.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
layerEventManager.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);
}
let visibleTrackSegmentIds: string[] = [];
file.forEachSegment((segment, trackIndex, segmentIndex) => {
if (!segment._data.hidden) {
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
}
});
const segmentFilter: FilterSpecification = [
'in',
['get', 'trackSegmentId'],
['literal', visibleTrackSegmentIds],
];
_map.setFilter(this.fileId, segmentFilter, { validate: false });
if (get(directionMarkers)) {
if (!_map.getLayer(this.fileId + '-direction')) {
_map.addLayer(
@@ -251,7 +198,7 @@ export class GPXLayer {
source: this.fileId,
layout: {
'text-field': '»',
'text-offset': [0, -0.06],
'text-offset': [0, -0.1],
'text-keep-upright': false,
'text-max-angle': 361,
'text-allow-overlap': true,
@@ -261,140 +208,177 @@ export class GPXLayer {
},
paint: {
'text-color': 'white',
'text-opacity': 0.7,
'text-halo-width': 0.2,
'text-halo-color': 'white',
},
},
ANCHOR_LAYER_KEY.directionMarkers
_map.getLayer('distance-markers-100') ? 'distance-markers-100' : undefined
);
}
_map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false });
} else {
if (_map.getLayer(this.fileId + '-direction')) {
_map.removeLayer(this.fileId + '-direction');
}
}
let waypointSource = _map.getSource(this.fileId + '-waypoints') as
| GeoJSONSource
| undefined;
this.currentWaypointData = this.getWaypointsGeoJSON();
if (waypointSource) {
waypointSource.setData(this.currentWaypointData);
} else {
_map.addSource(this.fileId + '-waypoints', {
type: 'geojson',
data: this.currentWaypointData,
promoteId: 'waypointIndex',
});
}
if (!_map.getLayer(this.fileId + '-waypoints')) {
_map.addLayer(
{
id: this.fileId + '-waypoints',
type: 'symbol',
source: this.fileId + '-waypoints',
layout: {
'icon-image': ['get', 'icon'],
'icon-size': 0.3,
'icon-anchor': 'bottom',
'icon-padding': 0,
'icon-allow-overlap': true,
},
},
ANCHOR_LAYER_KEY.waypoints
);
layerEventManager.on(
'mouseenter',
this.fileId + '-waypoints',
this.waypointLayerOnMouseEnterBinded
);
layerEventManager.on(
'mouseleave',
this.fileId + '-waypoints',
this.waypointLayerOnMouseLeaveBinded
);
layerEventManager.on(
'click',
this.fileId + '-waypoints',
this.waypointLayerOnClickBinded
);
layerEventManager.on(
'mousedown',
this.fileId + '-waypoints',
this.waypointLayerOnMouseDownBinded
);
layerEventManager.on(
'touchstart',
this.fileId + '-waypoints',
this.waypointLayerOnTouchStartBinded
);
}
let visibleWaypoints: number[] = [];
file.wpt.forEach((waypoint, waypointIndex) => {
if (!waypoint._data.hidden) {
visibleWaypoints.push(waypointIndex);
let visibleItems: [number, number][] = [];
file.forEachSegment((segment, trackIndex, segmentIndex) => {
if (!segment._data.hidden) {
visibleItems.push([trackIndex, segmentIndex]);
}
});
_map.setFilter(
this.fileId + '-waypoints',
['in', ['get', 'waypointIndex'], ['literal', visibleWaypoints]],
this.fileId,
[
'any',
...visibleItems.map(([trackIndex, segmentIndex]) => [
'all',
['==', 'trackIndex', trackIndex],
['==', 'segmentIndex', segmentIndex],
]),
],
{ validate: false }
);
if (_map.getLayer(this.fileId + '-direction')) {
_map.setFilter(
this.fileId + '-direction',
[
'any',
...visibleItems.map(([trackIndex, segmentIndex]) => [
'all',
['==', 'trackIndex', trackIndex],
['==', 'segmentIndex', segmentIndex],
]),
],
{ validate: false }
);
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
return;
}
let markerIndex = 0;
if (get(selection).hasAnyChildren(new ListFileItem(this.fileId))) {
file.wpt.forEach((waypoint) => {
// Update markers
let symbolKey = getSymbolKey(waypoint.sym);
if (markerIndex < this.markers.length) {
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(
symbolKey,
this.layerColor
);
this.markers[markerIndex].setLngLat(waypoint.getCoordinates());
Object.defineProperty(this.markers[markerIndex], '_waypoint', {
value: waypoint,
writable: true,
});
} else {
let element = document.createElement('div');
element.classList.add('w-8', 'h-8', 'drop-shadow-xl');
element.innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
let marker = new mapboxgl.Marker({
draggable: this.draggable,
element,
anchor: 'bottom',
}).setLngLat(waypoint.getCoordinates());
Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true });
let dragEndTimestamp = 0;
marker.getElement().addEventListener('mousemove', (e) => {
if (marker._isDragging) {
return;
}
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
e.stopPropagation();
});
marker.getElement().addEventListener('click', (e) => {
if (dragEndTimestamp && Date.now() - dragEndTimestamp < 1000) {
return;
}
if (get(currentTool) === Tool.WAYPOINT && e.shiftKey) {
fileActions.deleteWaypoint(this.fileId, marker._waypoint._data.index);
e.stopPropagation();
return;
}
if (get(treeFileView)) {
if (
(e.ctrlKey || e.metaKey) &&
get(selection).hasAnyChildren(
new ListWaypointsItem(this.fileId),
false
)
) {
selection.addSelectItem(
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
);
} else {
selection.selectItem(
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
);
}
} else if (get(currentTool) === Tool.WAYPOINT) {
selectedWaypoint.set([marker._waypoint, this.fileId]);
} else {
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
}
e.stopPropagation();
});
marker.on('dragstart', () => {
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, true);
marker.getElement().style.cursor = 'grabbing';
waypointPopup?.hide();
});
marker.on('dragend', (e) => {
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
marker.getElement().style.cursor = '';
getElevation([marker._waypoint]).then((ele) => {
fileActionManager.applyToFile(this.fileId, (file) => {
let latLng = marker.getLngLat();
let wpt = file.wpt[marker._waypoint._data.index];
wpt.setCoordinates({
lat: latLng.lat,
lon: latLng.lng,
});
wpt.ele = ele[0];
});
});
dragEndTimestamp = Date.now();
});
this.markers.push(marker);
}
markerIndex++;
});
}
while (markerIndex < this.markers.length) {
// Remove extra markers
this.markers.pop()?.remove();
}
this.markers.forEach((marker) => {
if (!marker._waypoint._data.hidden) {
marker.addTo(_map);
} else {
marker.remove();
}
});
}
remove() {
const _map = get(map);
if (_map) {
_map.off('style.load', this.updateBinded);
}
_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);
const layerEventManager = map.layerEventManager;
if (layerEventManager) {
layerEventManager.off('click', this.fileId, this.layerOnClickBinded);
layerEventManager.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
layerEventManager.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
layerEventManager.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
layerEventManager.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
layerEventManager.off(
'mouseenter',
this.fileId + '-waypoints',
this.waypointLayerOnMouseEnterBinded
);
layerEventManager.off(
'mouseleave',
this.fileId + '-waypoints',
this.waypointLayerOnMouseLeaveBinded
);
layerEventManager.off(
'click',
this.fileId + '-waypoints',
this.waypointLayerOnClickBinded
);
layerEventManager.off(
'mousedown',
this.fileId + '-waypoints',
this.waypointLayerOnMouseDownBinded
);
layerEventManager.off(
'touchstart',
this.fileId + '-waypoints',
this.waypointLayerOnTouchStartBinded
);
}
if (_map) {
if (_map.getLayer(this.fileId + '-direction')) {
_map.removeLayer(this.fileId + '-direction');
}
@@ -404,17 +388,15 @@ export class GPXLayer {
if (_map.getSource(this.fileId)) {
_map.removeSource(this.fileId);
}
if (_map.getLayer(this.fileId + '-waypoints')) {
_map.removeLayer(this.fileId + '-waypoints');
}
if (_map.getSource(this.fileId + '-waypoints')) {
_map.removeSource(this.fileId + '-waypoints');
}
}
this.markers.forEach((marker) => {
marker.remove();
});
this.unsubscribe.forEach((unsubscribe) => unsubscribe());
removeColor(this.fileId, this.layerColor);
decrementColor(this.layerColor);
}
moveToFront() {
@@ -423,13 +405,10 @@ export class GPXLayer {
return;
}
if (_map.getLayer(this.fileId)) {
_map.moveLayer(this.fileId, ANCHOR_LAYER_KEY.tracks);
}
if (_map.getLayer(this.fileId + '-waypoints')) {
_map.moveLayer(this.fileId + '-waypoints', ANCHOR_LAYER_KEY.waypoints);
_map.moveLayer(this.fileId);
}
if (_map.getLayer(this.fileId + '-direction')) {
_map.moveLayer(this.fileId + '-direction', ANCHOR_LAYER_KEY.directionMarkers);
_map.moveLayer(this.fileId + '-direction');
}
}
@@ -470,7 +449,7 @@ export class GPXLayer {
}
}
layerOnClick(e: MapLayerMouseEvent) {
layerOnClick(e: any) {
if (
get(currentTool) === Tool.ROUTING &&
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
@@ -478,8 +457,8 @@ export class GPXLayer {
return;
}
let trackIndex = e.features![0].properties!.trackIndex;
let segmentIndex = e.features![0].properties!.segmentIndex;
let trackIndex = e.features[0].properties.trackIndex;
let segmentIndex = e.features[0].properties.segmentIndex;
if (
get(currentTool) === Tool.SCISSORS &&
@@ -487,11 +466,6 @@ export class GPXLayer {
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
) {
if (get(map)?.queryRenderedFeatures(e.point, { layers: ['split-controls'] }).length) {
// Clicked on split control, ignoring
return;
}
fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, {
lat: e.lngLat.lat,
lon: e.lngLat.lng,
@@ -528,179 +502,6 @@ export class GPXLayer {
}
}
waypointLayerOnMouseEnter(e: MapLayerMouseEvent) {
if (this.draggedWaypointIndex !== null) {
return;
}
let file = get(this.file)?.file;
if (!file) {
return;
}
let waypointIndex = e.features![0].properties!.waypointIndex;
let waypoint = file.wpt[waypointIndex];
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true);
}
waypointLayerOnMouseLeave() {
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false);
}
waypointLayerOnClick(e: MapLayerMouseEvent) {
e.preventDefault();
let waypointIndex = e.features![0].properties!.waypointIndex;
let file = get(this.file)?.file;
if (!file) {
return;
}
let waypoint = file.wpt[waypointIndex];
if (get(currentTool) === Tool.WAYPOINT) {
if (this.selected) {
if (e.originalEvent.shiftKey) {
fileActions.deleteWaypoint(this.fileId, waypointIndex);
} else {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
selectedWaypoint.set([waypoint, this.fileId]);
}
} else {
if (get(treeFileView)) {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
} else {
selection.selectItem(new ListFileItem(this.fileId));
}
selectedWaypoint.set([waypoint, this.fileId]);
}
} else {
if (get(treeFileView)) {
if ((e.originalEvent.ctrlKey || e.originalEvent.metaKey) && this.selected) {
selection.addSelectItem(new ListWaypointItem(this.fileId, waypointIndex));
} else {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
}
} else {
if (!this.selected) {
selection.selectItem(new ListFileItem(this.fileId));
}
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
}
}
}
waypointLayerOnMouseDown(e: MapLayerMouseEvent) {
if (get(currentTool) !== Tool.WAYPOINT || !this.selected) {
return;
}
const _map = get(map);
if (!_map) {
return;
}
e.preventDefault();
_map.dragPan.disable();
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
this.draggingStartingPosition = e.point;
waypointPopup?.hide();
_map.on('mousemove', this.waypointLayerOnMouseMoveBinded);
_map.once('mouseup', this.waypointLayerOnMouseUpBinded);
}
waypointLayerOnTouchStart(e: MapLayerTouchEvent) {
if (e.points.length !== 1 || get(currentTool) !== Tool.WAYPOINT || !this.selected) {
return;
}
const _map = get(map);
if (!_map) {
return;
}
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
this.draggingStartingPosition = e.point;
waypointPopup?.hide();
e.preventDefault();
_map.dragPan.disable();
_map.on('touchmove', this.waypointLayerOnMouseMoveBinded);
_map.once('touchend', this.waypointLayerOnMouseUpBinded);
}
waypointLayerOnMouseMove(e: MapLayerMouseEvent | MapLayerTouchEvent) {
if (this.draggedWaypointIndex === null || e.point.equals(this.draggingStartingPosition)) {
return;
}
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, true);
(
this.currentWaypointData!.features[this.draggedWaypointIndex].geometry as GeoJSON.Point
).coordinates = [e.lngLat.lng, e.lngLat.lat];
let waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as
| GeoJSONSource
| undefined;
if (waypointSource) {
waypointSource.updateData({
update: [
{
id: this.draggedWaypointIndex,
newGeometry: {
type: 'Point',
coordinates: [e.lngLat.lng, e.lngLat.lat],
},
},
],
});
}
}
waypointLayerOnMouseUp(e: MapLayerMouseEvent | MapLayerTouchEvent) {
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
const _map = get(map);
if (!_map) {
return;
}
_map.dragPan.enable();
_map.off('mousemove', this.waypointLayerOnMouseMoveBinded);
_map.off('touchmove', this.waypointLayerOnMouseMoveBinded);
if (this.draggedWaypointIndex === null) {
return;
}
if (e.point.equals(this.draggingStartingPosition)) {
this.draggedWaypointIndex = null;
return;
}
getElevation([
{
lat: e.lngLat.lat,
lon: e.lngLat.lng,
},
]).then((ele) => {
if (this.draggedWaypointIndex === null) {
return;
}
fileActionManager.applyToFile(this.fileId, (file) => {
let wpt = file.wpt[this.draggedWaypointIndex!];
wpt.setCoordinates({
lat: e.lngLat.lat,
lon: e.lngLat.lng,
});
wpt.ele = ele[0];
});
this.draggedWaypointIndex = null;
});
}
getGeoJSON(): GeoJSON.FeatureCollection {
let file = get(this.file)?.file;
if (!file) {
@@ -738,7 +539,6 @@ export class GPXLayer {
}
feature.properties.trackIndex = trackIndex;
feature.properties.segmentIndex = segmentIndex;
feature.properties.trackSegmentId = `${trackIndex}-${segmentIndex}`;
segmentIndex++;
if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
@@ -748,52 +548,4 @@ export class GPXLayer {
}
return data;
}
getWaypointsGeoJSON(): GeoJSON.FeatureCollection {
let file = get(this.file)?.file;
let data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
};
if (!file) {
return data;
}
file.wpt.forEach((waypoint, index) => {
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [waypoint.getLongitude(), waypoint.getLatitude()],
},
properties: {
fileId: this.fileId,
waypointIndex: index,
icon: `waypoint-${getSymbolKey(waypoint.sym) ?? 'default'}-${this.layerColor}`,
},
});
});
return data;
}
loadIcons() {
const _map = get(map);
let file = get(this.file)?.file;
if (!_map || !file) {
return;
}
let symbols = new Set<string | undefined>();
file.wpt.forEach((waypoint) => {
symbols.add(getSymbolKey(waypoint.sym));
});
symbols.forEach((symbol) => {
const iconId = `waypoint-${symbol ?? 'default'}-${this.layerColor}`;
loadSVGIcon(_map, iconId, getSvgForSymbol(symbol, this.layerColor));
});
}
}
@@ -1,5 +1,4 @@
import { GPXFileStateCollectionObserver } from '$lib/logic/file-state';
import { writable } from 'svelte/store';
import { GPXLayer } from './gpx-layer';
export class GPXLayerCollection {
@@ -43,4 +42,3 @@ export class GPXLayerCollection {
}
export const gpxLayers = new GPXLayerCollection();
export const gpxColors = writable(new Map<string, string>());
@@ -1,40 +1,30 @@
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
import type { GeoJSONSource } from 'maplibre-gl';
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';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { loadSVGIcon } from '$lib/utils';
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6" fill="#22c55e" stroke="white" stroke-width="1.5"/>
</svg>`;
const endMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="checkerboard" x="0" y="0" width="5" height="5" patternUnits="userSpaceOnUse">
<rect x="0" y="0" width="2.5" height="2.5" fill="white"/>
<rect x="2.5" y="2.5" width="2.5" height="2.5" fill="white"/>
<rect x="2.5" y="0" width="2.5" height="2.5" fill="black"/>
<rect x="0" y="2.5" width="2.5" height="2.5" fill="black"/>
</pattern>
</defs>
<circle cx="8" cy="8" r="6" fill="url(#checkerboard)" stroke="white" stroke-width="1.5"/>
</svg>`;
const hoverMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6" fill="#00b8db" stroke="white" stroke-width="1.5"/>
</svg>`;
export class StartEndMarkers {
start: mapboxgl.Marker;
end: mapboxgl.Marker;
updateBinded: () => void = this.update.bind(this);
unsubscribes: (() => void)[] = [];
constructor() {
map.onLoad((map_) => map_.on('style.load', this.updateBinded));
let startElement = document.createElement('div');
let endElement = document.createElement('div');
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
endElement.className = `h-4 w-4 rounded-full border-2 border-white`;
endElement.style.background =
'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
this.start = new mapboxgl.Marker({ element: startElement });
this.end = new mapboxgl.Marker({ element: endElement });
map.onLoad(() => this.update());
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(hoveredPoint.subscribe(this.updateBinded));
this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
}
@@ -43,115 +33,26 @@ export class StartEndMarkers {
const map_ = get(map);
if (!map_) return;
this.loadIcons();
const tool = get(currentTool);
const statistics = get(gpxStatistics);
const slicedStatistics = get(slicedGPXStatistics);
const hovered = get(hoveredPoint);
const statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
const hidden = get(allHidden);
if (!hidden) {
const data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
};
if (statistics.global.length > 0 && tool !== Tool.ROUTING) {
const start = statistics
.getTrackPoint(slicedStatistics?.[1] ?? 0)!
.trkpt.getCoordinates();
const end = statistics
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
.trkpt.getCoordinates();
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [start.lon, start.lat],
},
properties: {
icon: 'start-marker',
},
});
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [end.lon, end.lat],
},
properties: {
icon: 'end-marker',
},
});
}
if (hovered) {
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [hovered.lon, hovered.lat],
},
properties: {
icon: 'hover-marker',
},
});
}
let source = map_.getSource('start-end-markers') as GeoJSONSource | undefined;
if (source) {
source.setData(data);
} else {
map_.addSource('start-end-markers', {
type: 'geojson',
data: data,
});
}
if (!map_.getLayer('start-end-markers')) {
map_.addLayer(
{
id: 'start-end-markers',
type: 'symbol',
source: 'start-end-markers',
layout: {
'icon-image': ['get', 'icon'],
'icon-size': 0.2,
'icon-allow-overlap': true,
},
},
ANCHOR_LAYER_KEY.startEndMarkers
);
}
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(map_);
} else {
if (map_.getLayer('start-end-markers')) {
map_.removeLayer('start-end-markers');
}
if (map_.getSource('start-end-markers')) {
map_.removeSource('start-end-markers');
}
this.start.remove();
this.end.remove();
}
}
remove() {
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
const map_ = get(map);
if (!map_) return;
if (map_.getLayer('start-end-markers')) {
map_.removeLayer('start-end-markers');
}
if (map_.getSource('start-end-markers')) {
map_.removeSource('start-end-markers');
}
}
loadIcons() {
const map_ = get(map);
if (!map_) return;
loadSVGIcon(map_, 'start-marker', startMarkerSVG);
loadSVGIcon(map_, 'end-marker', endMarkerSVG);
loadSVGIcon(map_, 'hover-marker', hoverMarkerSVG);
this.start.remove();
this.end.remove();
}
}
@@ -20,8 +20,9 @@
import { i18n } from '$lib/i18n.svelte';
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
import { onMount } from 'svelte';
import { remove } from './utils';
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 {
@@ -41,8 +42,13 @@
let maxZoom: number = $state(20);
let layerType: 'basemap' | 'overlay' = $state('basemap');
let resourceType: 'raster' | 'vector' = $derived.by(() => {
if (tileUrls[0].length > 0 && tileUrls[0].includes('.json')) {
return 'vector';
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';
});
@@ -128,8 +134,8 @@
],
};
}
addLayer(layerId);
$customLayers[layerId] = layer;
addLayer(layerId);
selectedLayerId = undefined;
setDataFromSelectedLayer();
}
@@ -152,7 +158,9 @@
return $tree;
});
if ($currentBasemap !== layerId) {
if ($currentBasemap === layerId) {
$customBasemapUpdate++;
} else {
$currentBasemap = layerId;
}
@@ -168,6 +176,14 @@
return $tree;
});
if ($map && $currentOverlays && isSelected($currentOverlays, layerId)) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
currentOverlays.update(($overlays) => {
if (!$overlays.overlays.hasOwnProperty('custom')) {
$overlays.overlays['custom'] = {};
@@ -232,127 +248,120 @@
<div class="flex flex-col">
{#if $customBasemapOrder.length > 0}
<div class="px-3 py-2">
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Map size="16" />
{i18n._('layers.label.basemaps')}
</div>
<div
class="ml-1.5 flex flex-col gap-1"
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 customBasemapItems as item (item.id)}
<div class="flex flex-row items-center gap-1">
<Move size="12" />
<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"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Map size="16" />
{i18n._('layers.label.basemaps')}
<div class="grow">
<Separator />
</div>
</div>
<Separator />
{/if}
<div
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 customBasemapItems as item (item.id)}
<div class="flex flex-row items-center gap-2">
<Move size="12" />
<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"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
{#if $customOverlayOrder.length > 0}
<div class="px-3 py-2">
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Layers2 size="16" />
{i18n._('layers.label.overlays')}
<div class="grow"></div>
</div>
<div
class="ml-1.5 flex flex-col gap-1"
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 customOverlayItems as item (item.id)}
<div class="flex flex-row items-center gap-1">
<Move size="12" />
<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"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Layers2 size="16" />
{i18n._('layers.label.overlays')}
<div class="grow">
<Separator />
</div>
</div>
<Separator />
{/if}
<Card.Root class="py-0 gap-0 shadow-none ring-0">
<div
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 customOverlayItems as item (item.id)}
<div class="flex flex-row items-center gap-2">
<Move size="12" />
<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"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
<Card.Root class="py-0 gap-0 shadow-none">
<Card.Header class="p-3">
<Card.Title class="text-sm font-semibold">
<Card.Title class="text-base">
{#if selectedLayerId}
{i18n._('layers.custom_layers.edit')}
{:else}
@@ -360,7 +369,7 @@
{/if}
</Card.Title>
</Card.Header>
<Card.Content class="px-3 py-2">
<Card.Content class="p-3 pt-0">
<fieldset class="flex flex-col gap-2">
<Label for="name">{i18n._('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="h-8" />
@@ -417,7 +426,7 @@
</div>
</RadioGroup.Root>
{#if selectedLayerId}
<div class="mt-2 flex flex-row gap-1">
<div class="mt-2 flex flex-row gap-2">
<Button variant="outline" onclick={createLayer} class="grow">
<Save size="16" />
{i18n._('layers.custom_layers.update')}
@@ -5,8 +5,12 @@
import { Separator } from '$lib/components/ui/separator';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { Layers } from '@lucide/svelte';
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
import { settings } from '$lib/logic/settings';
import { map } from '$lib/components/map/map';
import { customBasemapUpdate, getLayers } from './utils';
import type { ImportSpecification, StyleSpecification } from 'mapbox-gl';
import { untrack } from 'svelte';
let container: HTMLDivElement;
let overpassLayer: OverpassLayer;
@@ -19,14 +23,127 @@
selectedBasemapTree,
selectedOverlayTree,
selectedOverpassTree,
customLayers,
opacities,
} = settings;
map.onLoad((_map: maplibregl.Map) => {
function setStyle() {
if (!$map) {
return;
}
let basemap = basemaps.hasOwnProperty($currentBasemap)
? basemaps[$currentBasemap]
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
$map.removeImport('basemap');
if (typeof basemap === 'string') {
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
} else {
$map.addImport(
{
id: 'basemap',
url: '',
data: basemap as StyleSpecification,
},
'overlays'
);
}
}
$effect(() => {
if ($map && ($currentBasemap || $customBasemapUpdate)) {
untrack(() => setStyle());
}
});
function addOverlay(id: string) {
if (!$map) {
return;
}
try {
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
if (typeof overlay === 'string') {
$map.addImport({ id, url: overlay });
} else {
if ($opacities.hasOwnProperty(id)) {
overlay = {
...overlay,
layers: (overlay as StyleSpecification).layers.map((layer) => {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = $opacities[id];
}
return layer;
}),
};
}
$map.addImport({
id,
url: '',
data: overlay as StyleSpecification,
});
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
function updateOverlays() {
if ($map && $currentOverlays && $opacities) {
let overlayLayers = getLayers($currentOverlays);
try {
let activeOverlays =
$map
.getStyle()
.imports?.reduce(
(
acc: Record<string, ImportSpecification>,
imprt: ImportSpecification
) => {
if (
!['basemap', 'overlays', 'glyphs-and-sprite'].includes(imprt.id)
) {
acc[imprt.id] = imprt;
}
return acc;
},
{}
) || {};
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
toRemove.forEach((id) => {
$map?.removeImport(id);
});
let toAdd = Object.entries(overlayLayers)
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
.map(([id]) => id);
toAdd.forEach((id) => {
addOverlay(id);
});
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
}
$effect(() => {
if ($map && $currentOverlays && $opacities) {
untrack(() => updateOverlays());
}
});
map.onLoad((_map: mapboxgl.Map) => {
if (overpassLayer) {
overpassLayer.remove();
}
overpassLayer = new OverpassLayer(_map, map.layerEventManager!);
overpassLayer = new OverpassLayer(_map);
overpassLayer.add();
let first = true;
_map.on('style.import.load', () => {
if (!first) return;
first = false;
updateOverlays();
});
});
let open = $state(false);
@@ -13,7 +13,6 @@
overlays,
overlayTree,
overpassTree,
terrainSources,
} from '$lib/assets/layers';
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n.svelte';
@@ -32,7 +31,6 @@
currentOverpassQueries,
customLayers,
opacities,
terrainSource,
} = settings;
const { isLayerFromExtension, getLayerName } = extensionAPI;
@@ -56,7 +54,7 @@
}
$effect(() => {
if (open && $selectedBasemapTree && $currentBasemap) {
if ($selectedBasemapTree && $currentBasemap) {
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
@@ -67,7 +65,7 @@
});
$effect(() => {
if (open && $selectedOverlayTree) {
if ($selectedOverlayTree) {
untrack(() => {
if ($currentOverlays) {
let overlayLayers = getLayers($currentOverlays);
@@ -88,7 +86,7 @@
});
$effect(() => {
if (open && $selectedOverpassTree) {
if ($selectedOverpassTree) {
untrack(() => {
if ($currentOverpassQueries) {
let overlayLayers = getLayers($currentOverpassQueries);
@@ -121,7 +119,7 @@
<Accordion.Root class="flex flex-col" bind:value={accordionValue} type="single">
<Accordion.Item value="layer-selection" class="flex flex-col">
<Accordion.Trigger>{i18n._('layers.selection')}</Accordion.Trigger>
<Accordion.Content class="grow flex flex-col border rounded-md mb-1.5">
<Accordion.Content class="grow flex flex-col border rounded">
<div class="py-2 pl-3 pr-2">
<LayerTree
layerTree={basemapTree}
@@ -152,9 +150,7 @@
</Accordion.Item>
<Accordion.Item value="overlay-opacity">
<Accordion.Trigger>{i18n._('layers.opacity')}</Accordion.Trigger>
<Accordion.Content
class="flex flex-col gap-3 overflow-visible border rounded-md px-3 py-2 mb-1.5"
>
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
<div class="flex flex-row gap-6 items-center">
<Label>
{i18n._('layers.custom_layers.overlay')}
@@ -164,16 +160,16 @@
type="single"
onValueChange={setOpacityFromSelection}
>
<Select.Trigger class="mr-1 w-full" size="sm">
<Select.Trigger class="h-8 mr-1 w-full">
{#if selectedOverlay}
{#if isSelected($selectedOverlayTree, selectedOverlay)}
{#if $isLayerFromExtension(selectedOverlay)}
{$getLayerName(selectedOverlay)}
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
{$customLayers[selectedOverlay].name}
{:else}
{i18n._(`layers.label.${selectedOverlay}`)}
{/if}
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
{$customLayers[selectedOverlay].name}
{/if}
{/if}
</Select.Trigger>
@@ -215,9 +211,7 @@
isSelected($currentOverlays, selectedOverlay)
) {
try {
if ($map.getLayer(selectedOverlay)) {
$map.removeLayer(selectedOverlay);
}
$map.removeImport(selectedOverlay);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
@@ -233,27 +227,10 @@
<Accordion.Item value="custom-layers">
<Accordion.Trigger>{i18n._('layers.custom_layers.title')}</Accordion.Trigger
>
<Accordion.Content
class="flex flex-col overflow-visible border rounded-md p-0 mb-1.5"
>
<CustomLayers />
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="terrain-source">
<Accordion.Trigger>{i18n._('layers.terrain')}</Accordion.Trigger>
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
<Select.Root bind:value={$terrainSource} type="single">
<Select.Trigger class="mr-1 w-full" size="sm">
{i18n._(`layers.label.${$terrainSource}`)}
</Select.Trigger>
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
{#each Object.keys(terrainSources) as id}
<Select.Item value={id}>
{i18n._(`layers.label.${id}`)}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Accordion.Content>
<ScrollArea>
<CustomLayers />
</ScrollArea>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
@@ -85,7 +85,7 @@
{:else if anySelectedLayer(node[id])}
<CollapsibleTreeNode {id}>
{#snippet trigger()}
<span>{i18n._(`layers.label.${id}`, id)}</span>
<span>{i18n._(`layers.label.${id}`)}</span>
{/snippet}
{#snippet content()}
<div class="ml-2">
@@ -54,27 +54,28 @@
<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 flex flex-row">
<div class="flex flex-col">
<p>{name}</p>
<div class="text-muted-foreground text-xs font-normal">
{poi.item.lat.toFixed(6)}&deg; {poi.item.lon.toFixed(6)}&deg;
<Card.Title class="text-md">
<div class="flex flex-row gap-3">
<div class="flex flex-col">
{name}
<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"
variant="outline"
size="icon"
href="https://www.openstreetmap.org/edit?editor=id&{poi.item.type ??
'node'}={poi.item.id}"
target="_blank"
>
<PencilLine size="16" />
</Button>
</div>
<Button
class="ml-auto"
variant="outline"
size="icon-sm"
href="https://www.openstreetmap.org/edit?editor=id&{poi.item.type ?? 'node'}={poi
.item.id}"
target="_blank"
>
<PencilLine size="16" />
</Button>
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-1 p-0 text-sm whitespace-normal break-all">
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
<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">
@@ -99,14 +100,8 @@
{/each}
</div>
</ScrollArea>
<Button
size="sm"
class="mt-1 justify-start"
variant="outline"
disabled={$selection.size === 0}
onclick={addToFile}
>
<MapPin size="14" />
<Button class="mt-2" variant="outline" disabled={$selection.size === 0} onclick={addToFile}>
<MapPin size="16" />
{i18n._('toolbar.waypoint.add')}
</Button>
</Card.Content>
@@ -8,7 +8,6 @@ import { map } from '$lib/components/map/map';
const { currentOverlays, previousOverlays, selectedOverlayTree } = settings;
export type CustomOverlay = {
extensionName: string;
id: string;
name: string;
tileUrls: string[];
@@ -47,16 +46,8 @@ export class ExtensionAPI {
}
addOrUpdateOverlay(overlay: CustomOverlay) {
if (
!overlay.extensionName ||
!overlay.id ||
!overlay.name ||
!overlay.tileUrls ||
overlay.tileUrls.length === 0
) {
throw new Error(
'Overlay must have an extensionName, id, name, and at least one tile URL.'
);
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);
@@ -84,17 +75,10 @@ export class ExtensionAPI {
],
};
if (!overlayTree.overlays.hasOwnProperty(overlay.extensionName)) {
overlayTree.overlays[overlay.extensionName] = {};
}
overlayTree.overlays[overlay.extensionName][overlay.id] = true;
overlayTree.overlays.world[overlay.id] = true;
selectedOverlayTree.update((selected) => {
if (!selected.overlays.hasOwnProperty(overlay.extensionName)) {
selected.overlays[overlay.extensionName] = {};
}
selected.overlays[overlay.extensionName][overlay.id] = true;
selected.overlays.world[overlay.id] = true;
return selected;
});
@@ -103,17 +87,14 @@ export class ExtensionAPI {
if (current && isSelected(current, overlay.id)) {
show = true;
try {
get(map)?.removeLayer(overlay.id);
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) => {
if (!current.overlays.hasOwnProperty(overlay.extensionName)) {
current.overlays[overlay.extensionName] = {};
}
current.overlays[overlay.extensionName][overlay.id] = show;
current.overlays.world[overlay.id] = show;
return current;
});
}
@@ -152,29 +133,6 @@ export class ExtensionAPI {
});
}
updateOverlaysOrder(ids: string[]) {
ids = ids.map((id) => this.getOverlayId(id));
selectedOverlayTree.update((selected) => {
let isSelected: Record<string, boolean> = {};
ids.forEach((id) => {
const overlay = get(this._overlays).get(id);
if (
overlay &&
selected.overlays.hasOwnProperty(overlay.extensionName) &&
selected.overlays[overlay.extensionName].hasOwnProperty(id)
) {
isSelected[id] = selected.overlays[overlay.extensionName][id];
delete selected.overlays[overlay.extensionName][id];
}
});
Object.entries(isSelected).forEach(([id, value]) => {
const overlay = get(this._overlays).get(id)!;
selected.overlays[overlay.extensionName][id] = value;
});
return selected;
});
}
isLayerFromExtension = derived(this._overlays, ($overlays) => {
return (id: string) => $overlays.has(id);
});
@@ -6,10 +6,6 @@ import { overpassQueryData } from '$lib/assets/layers';
import { MapPopup } from '$lib/components/map/map-popup';
import { settings } from '$lib/logic/settings';
import { db } from '$lib/db';
import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
const { currentOverpassQueries } = settings;
@@ -28,8 +24,7 @@ export class OverpassLayer {
minZoom = 12;
queryZoom = 12;
expirationTime = 7 * 24 * 3600 * 1000;
map: maplibregl.Map;
layerEventManager: MapLayerEventManager;
map: mapboxgl.Map;
popup: MapPopup;
currentQueries: Set<string> = new Set();
@@ -40,9 +35,8 @@ export class OverpassLayer {
updateBinded = this.update.bind(this);
onHoverBinded = this.onHover.bind(this);
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
constructor(map: mapboxgl.Map) {
this.map = map;
this.layerEventManager = layerEventManager;
this.popup = new MapPopup(map, {
closeButton: false,
focusAfterOpen: false,
@@ -53,7 +47,7 @@ export class OverpassLayer {
add() {
this.map.on('moveend', this.queryIfNeededBinded);
this.map.on('style.load', this.updateBinded);
this.map.on('style.import.load', this.updateBinded);
this.unsubscribes.push(data.subscribe(this.updateBinded));
this.unsubscribes.push(
currentOverpassQueries.subscribe(() => {
@@ -77,17 +71,10 @@ export class OverpassLayer {
update() {
this.loadIcons();
const fullData = get(data);
const queries = getCurrentQueries();
const d: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: fullData.features.filter((feature) =>
queries.includes(feature.properties!.query)
),
};
let d = get(data);
try {
let source = this.map.getSource('overpass') as GeoJSONSource | undefined;
let source = this.map.getSource('overpass') as mapboxgl.GeoJSONSource | undefined;
if (source) {
source.setData(d);
} else {
@@ -98,24 +85,23 @@ export class OverpassLayer {
}
if (!this.map.getLayer('overpass')) {
this.map.addLayer(
{
id: 'overpass',
type: 'symbol',
source: 'overpass',
layout: {
'icon-image': ['get', 'icon'],
'icon-size': 0.25,
'icon-padding': 0,
'icon-allow-overlap': ['step', ['zoom'], false, 14, true],
},
this.map.addLayer({
id: 'overpass',
type: 'symbol',
source: 'overpass',
layout: {
'icon-image': ['get', 'icon'],
'icon-size': 0.25,
'icon-padding': 0,
'icon-allow-overlap': ['step', ['zoom'], false, 14, true],
},
ANCHOR_LAYER_KEY.overpass
);
});
this.layerEventManager.on('mouseenter', 'overpass', this.onHoverBinded);
this.layerEventManager.on('click', 'overpass', this.onHoverBinded);
this.map.on('mouseenter', 'overpass', this.onHoverBinded);
this.map.on('click', 'overpass', this.onHoverBinded);
}
this.map.setFilter('overpass', ['in', 'query', ...getCurrentQueries()]);
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
@@ -123,9 +109,7 @@ export class OverpassLayer {
remove() {
this.map.off('moveend', this.queryIfNeededBinded);
this.map.off('style.load', this.updateBinded);
this.layerEventManager.off('mouseenter', 'overpass', this.onHoverBinded);
this.layerEventManager.off('click', 'overpass', this.onHoverBinded);
this.map.off('style.import.load', this.updateBinded);
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
try {
@@ -258,16 +242,27 @@ export class OverpassLayer {
loadIcons() {
let currentQueries = getCurrentQueries();
currentQueries.forEach((query) => {
loadSVGIcon(
this.map,
`overpass-${query}`,
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
if (!this.map.hasImage(`overpass-${query}`)) {
let icon = new Image(100, 100);
icon.onload = () => {
if (!this.map.hasImage(`overpass-${query}`)) {
this.map.addImage(`overpass-${query}`, icon);
}
};
// Lucide icons are SVG files with a 24x24 viewBox
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
icon.src =
'data:image/svg+xml,' +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
<g transform="translate(8 8)">
${overpassQueryData[query].icon.svg.replace('stroke="currentColor"', 'stroke="white"')}
</g>
</svg>`
);
</svg>
`);
}
});
}
}
@@ -288,12 +283,10 @@ function getQuery(query: string) {
}
}
function getQueryItem(tags: Record<string, string | string[]>) {
let arrayEntry = Object.entries(tags).find((entry): entry is [string, string[]] =>
Array.isArray(entry[1])
);
function getQueryItem(tags: Record<string, string | boolean | string[]>) {
let arrayEntry = Object.values(tags).find((value) => Array.isArray(value));
if (arrayEntry !== undefined) {
return arrayEntry[1]
return arrayEntry
.map(
(val) =>
`nwr${Object.entries(tags)
@@ -316,7 +309,7 @@ function belongsToQuery(element: any, query: string) {
}
}
function belongsToQueryItem(element: any, tags: Record<string, string | string[]>) {
function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) {
return Object.entries(tags).every(([tag, value]) =>
Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value
);
@@ -1,4 +1,5 @@
import type { LayerTreeType } from '$lib/assets/layers';
import { writable } from 'svelte/store';
export function anySelectedLayer(node: LayerTreeType) {
return (
@@ -75,3 +76,5 @@ export function removeAll(node: LayerTreeType, ids: string[]) {
});
return node;
}
export const customBasemapUpdate = writable(0);
@@ -1,282 +0,0 @@
import { fileStateCollection } from '$lib/logic/file-state';
import maplibregl from 'maplibre-gl';
type MapLayerMouseEventListener = (e: maplibregl.MapLayerMouseEvent) => void;
type MapLayerTouchEventListener = (e: maplibregl.MapLayerTouchEvent) => void;
type MapLayerListener = {
features: maplibregl.MapGeoJSONFeature[];
mousemoves: MapLayerMouseEventListener[];
mouseenters: MapLayerMouseEventListener[];
mouseleaves: MapLayerMouseEventListener[];
mousedowns: MapLayerMouseEventListener[];
clicks: MapLayerMouseEventListener[];
contextmenus: MapLayerMouseEventListener[];
touchstarts: MapLayerTouchEventListener[];
};
export class MapLayerEventManager {
private _map: maplibregl.Map;
private _listeners: Record<string, MapLayerListener> = {};
constructor(map: maplibregl.Map) {
this._map = map;
this._map.on('mousemove', this._handleMouseMove.bind(this));
this._map.on('click', this._handleMouseClick.bind(this, 'click'));
this._map.on('contextmenu', this._handleMouseClick.bind(this, 'contextmenu'));
this._map.on('mousedown', this._handleMouseClick.bind(this, 'mousedown'));
this._map.on('touchstart', this._handleTouchStart.bind(this));
}
on(
eventType:
| 'mousemove'
| 'mouseenter'
| 'mouseleave'
| 'mousedown'
| 'click'
| 'contextmenu'
| 'touchstart',
layerId: string,
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
) {
if (!this._listeners[layerId]) {
this._listeners[layerId] = {
features: [],
mousemoves: [],
mouseenters: [],
mouseleaves: [],
mousedowns: [],
clicks: [],
contextmenus: [],
touchstarts: [],
};
}
switch (eventType) {
case 'mousemove':
this._listeners[layerId].mousemoves.push(listener as MapLayerMouseEventListener);
break;
case 'mouseenter':
this._listeners[layerId].mouseenters.push(listener as MapLayerMouseEventListener);
break;
case 'mouseleave':
this._listeners[layerId].mouseleaves.push(listener as MapLayerMouseEventListener);
break;
case 'mousedown':
this._listeners[layerId].mousedowns.push(listener as MapLayerMouseEventListener);
break;
case 'click':
this._listeners[layerId].clicks.push(listener as MapLayerMouseEventListener);
break;
case 'contextmenu':
this._listeners[layerId].contextmenus.push(listener as MapLayerMouseEventListener);
break;
case 'touchstart':
this._listeners[layerId].touchstarts.push(listener as MapLayerTouchEventListener);
break;
}
}
off(
eventType:
| 'mousemove'
| 'mouseenter'
| 'mouseleave'
| 'mousedown'
| 'click'
| 'contextmenu'
| 'touchstart',
layerId: string,
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
) {
if (this._listeners[layerId]) {
switch (eventType) {
case 'mousemove':
this._listeners[layerId].mousemoves = this._listeners[
layerId
].mousemoves.filter((l) => l !== listener);
break;
case 'mouseenter':
this._listeners[layerId].mouseenters = this._listeners[
layerId
].mouseenters.filter((l) => l !== listener);
break;
case 'mouseleave':
this._listeners[layerId].mouseleaves = this._listeners[
layerId
].mouseleaves.filter((l) => l !== listener);
break;
case 'mousedown':
this._listeners[layerId].mousedowns = this._listeners[
layerId
].mousedowns.filter((l) => l !== listener);
break;
case 'click':
this._listeners[layerId].clicks = this._listeners[layerId].clicks.filter(
(l) => l !== listener
);
break;
case 'contextmenu':
this._listeners[layerId].contextmenus = this._listeners[
layerId
].contextmenus.filter((l) => l !== listener);
break;
case 'touchstart':
this._listeners[layerId].touchstarts = this._listeners[
layerId
].touchstarts.filter((l) => l !== listener);
break;
}
if (
this._listeners[layerId].mousemoves.length === 0 &&
this._listeners[layerId].mouseenters.length === 0 &&
this._listeners[layerId].mouseleaves.length === 0 &&
this._listeners[layerId].mousedowns.length === 0 &&
this._listeners[layerId].clicks.length === 0 &&
this._listeners[layerId].contextmenus.length === 0 &&
this._listeners[layerId].touchstarts.length === 0
) {
delete this._listeners[layerId];
}
}
}
private _handleMouseMove(e: maplibregl.MapMouseEvent) {
if (e.originalEvent.buttons > 0) return;
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId];
if ((features.length == 0) != (listener.features.length == 0)) {
if (features.length > 0) {
if (listener.mouseenters.length > 0) {
const event = new maplibregl.MapMouseEvent(
'mouseenter',
e.target,
e.originalEvent,
{
features: featuresByLayer[layerId]!,
}
);
listener.mouseenters.forEach((l) => l(event));
}
} else {
if (listener.mouseleaves.length > 0) {
const event = new maplibregl.MapMouseEvent(
'mouseleave',
e.target,
e.originalEvent
);
listener.mouseleaves.forEach((l) => l(event));
}
}
}
if (features.length > 0 && listener.mousemoves.length > 0) {
const event = new maplibregl.MapMouseEvent('mousemove', e.target, e.originalEvent, {
features: featuresByLayer[layerId]!,
});
listener.mousemoves.forEach((l) => l(event));
}
listener.features = features;
});
}
private _handleMouseClick(type: string, e: maplibregl.MapMouseEvent) {
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId];
if (features.length > 0) {
if (type === 'click' && listener.clicks.length > 0) {
const event = new maplibregl.MapMouseEvent('click', e.target, e.originalEvent, {
features: features,
});
listener.clicks.forEach((l) => l(event));
} else if (type === 'contextmenu' && listener.contextmenus.length > 0) {
const event = new maplibregl.MapMouseEvent(
'contextmenu',
e.target,
e.originalEvent,
{
features: features,
}
);
listener.contextmenus.forEach((l) => l(event));
} else if (type === 'mousedown' && listener.mousedowns.length > 0) {
const event = new maplibregl.MapMouseEvent(
'mousedown',
e.target,
e.originalEvent,
{
features: features,
}
);
listener.mousedowns.forEach((l) => l(event));
}
}
});
}
private _handleTouchStart(e: maplibregl.MapTouchEvent) {
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId];
if (features.length > 0) {
const event: maplibregl.MapLayerTouchEvent = new maplibregl.MapTouchEvent(
'touchstart',
e.target,
e.originalEvent
);
event.features = featuresByLayer[layerId]!;
listener.touchstarts.forEach((l) => l(event));
}
});
}
private _getBounds(point: maplibregl.Point) {
const delta = 30;
return new maplibregl.LngLatBounds(
this._map.unproject([point.x - delta, point.y + delta]),
this._map.unproject([point.x + delta, point.y - delta])
);
}
private _filterLayersIntersectingBounds(
layerIds: string[],
bounds: maplibregl.LngLatBounds
): string[] {
let result = layerIds.filter((layerId) => {
if (!this._map.getLayer(layerId)) return false;
const fileId = layerId.replace('-waypoints', '');
if (fileId === layerId) {
return fileStateCollection.getStatistics(fileId)?.intersectsBBox(bounds) ?? true;
} else {
return (
fileStateCollection.getStatistics(fileId)?.intersectsWaypointBBox(bounds) ??
true
);
}
});
return result;
}
private _getRenderedFeaturesByLayer(e: maplibregl.MapMouseEvent | maplibregl.MapTouchEvent) {
const layerIds = this._filterLayersIntersectingBounds(
Object.keys(this._listeners),
this._getBounds(e.point)
);
const features =
layerIds.length > 0
? this._map.queryRenderedFeatures(e.point, { layers: layerIds })
: [];
const featuresByLayer: Record<string, maplibregl.MapGeoJSONFeature[]> = {};
features.forEach((f) => {
if (!featuresByLayer[f.layer.id]) {
featuresByLayer[f.layer.id] = [];
}
featuresByLayer[f.layer.id].push(f);
});
return featuresByLayer;
}
}
+8 -8
View File
@@ -1,5 +1,5 @@
import { TrackPoint, Waypoint } from 'gpx';
import maplibregl from 'maplibre-gl';
import mapboxgl from 'mapbox-gl';
import { mount, tick, unmount } from 'svelte';
import { get, writable, type Writable } from 'svelte/store';
import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
@@ -11,15 +11,15 @@ export type PopupItem<T = Waypoint | TrackPoint | any> = {
};
export class MapPopup {
map: maplibregl.Map;
popup: maplibregl.Popup;
map: mapboxgl.Map;
popup: mapboxgl.Popup;
item: Writable<PopupItem | null> = writable(null);
component: ReturnType<typeof mount>;
maybeHideBinded = this.maybeHide.bind(this);
constructor(map: maplibregl.Map, options?: maplibregl.PopupOptions) {
constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
this.map = map;
this.popup = new maplibregl.Popup(options);
this.popup = new mapboxgl.Popup(options);
this.component = mount(MapPopupComponent, {
target: document.body,
props: {
@@ -51,7 +51,7 @@ export class MapPopup {
this.map.on('mousemove', this.maybeHideBinded);
}
maybeHide(e: maplibregl.MapMouseEvent) {
maybeHide(e: mapboxgl.MapMouseEvent) {
const item = get(this.item);
if (item === null) {
this.hide();
@@ -75,10 +75,10 @@ export class MapPopup {
getCoordinates() {
const item = get(this.item);
if (item === null) {
return new maplibregl.LngLat(0, 0);
return new mapboxgl.LngLat(0, 0);
}
return item.item instanceof Waypoint || item.item instanceof TrackPoint
? item.item.getCoordinates()
: new maplibregl.LngLat(item.item.lon, item.item.lat);
: new mapboxgl.LngLat(item.item.lon, item.item.lat);
}
}
+130 -83
View File
@@ -1,80 +1,100 @@
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import MaplibreGeocoder, {
type MaplibreGeocoderFeatureResults,
} from '@maplibre/maplibre-gl-geocoder';
import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css';
import 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';
import { ANCHOR_LAYER_KEY, StyleManager } from '$lib/components/map/style';
import { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings;
let fitBoundsOptions: maplibregl.MapOptions['fitBoundsOptions'] = {
let fitBoundsOptions: mapboxgl.MapOptions['fitBoundsOptions'] = {
maxZoom: 15,
linear: true,
easing: () => 1,
};
export class MapLibreGLMap {
private _maptilerKey: string = '';
private _map: maplibregl.Map | null = null;
private _mapStore: Writable<maplibregl.Map | null> = writable(null);
private _styleManager: StyleManager | null = null;
private _onLoadCallbacks: ((map: maplibregl.Map) => void)[] = [];
export class MapboxGLMap {
private _map: Writable<mapboxgl.Map | null> = writable(null);
private _onLoadCallbacks: ((map: mapboxgl.Map) => void)[] = [];
private _unsubscribes: (() => void)[] = [];
private callOnLoadBinded: () => void = this.callOnLoad.bind(this);
public layerEventManager: MapLayerEventManager | null = null;
subscribe(run: (value: maplibregl.Map | null) => void, invalidate?: () => void) {
return this._mapStore.subscribe(run, invalidate);
subscribe(run: (value: mapboxgl.Map | null) => void, invalidate?: () => void) {
return this._map.subscribe(run, invalidate);
}
init(
maptilerKey: string,
accessToken: string,
language: string,
hash: boolean,
geocoder: boolean,
geolocate: boolean
) {
this._maptilerKey = maptilerKey;
this._styleManager = new StyleManager(this._mapStore, this._maptilerKey);
const map = new maplibregl.Map({
const map = new mapboxgl.Map({
container: 'map',
style: {
version: 8,
projection: {
type: 'globe',
},
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,
maxPitch: 90,
});
this.layerEventManager = new MapLayerEventManager(map);
map.addControl(
new maplibregl.NavigationControl({
new mapboxgl.AttributionControl({
compact: true,
})
);
map.addControl(
new mapboxgl.NavigationControl({
visualizePitch: true,
})
);
if (geocoder) {
let geocoder = new MaplibreGeocoder(
{
forwardGeocode: async (config) => {
const results: MaplibreGeocoderFeatureResults = {
features: [],
type: 'FeatureCollection',
};
try {
const request = `https://nominatim.openstreetmap.org/search?format=json&q=${config.query}&limit=5&accept-language=${language}`;
const response = await fetch(request);
const geojson = await response.json();
results.features = geojson.map((result: any) => {
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: {
@@ -84,43 +104,74 @@ export class MapLibreGLMap {
place_name: result.display_name,
};
});
} catch (e) {}
return results;
},
},
{
maplibregl: maplibregl,
enableEventLogging: false,
collapsed: true,
flyTo: fitBoundsOptions,
language,
}),
});
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 maplibregl.GeolocateControl({
new mapboxgl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true,
},
fitBoundsOptions,
trackUserLocation: true,
showUserHeading: true,
})
);
}
const scaleControl = new maplibregl.ScaleControl({
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 = map;
this._mapStore.set(map); // only set the store after the map has loaded
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 = [];
});
map.on('style.load', this.callOnLoadBinded);
this._unsubscribes.push(treeFileView.subscribe(() => this.resize()));
this._unsubscribes.push(elevationProfile.subscribe(() => this.resize()));
@@ -133,48 +184,44 @@ export class MapLibreGLMap {
);
}
onLoad(callback: (map: mapboxgl.Map) => void) {
const map = get(this._map);
if (map) {
callback(map);
} else {
this._onLoadCallbacks.push(callback);
}
}
destroy() {
if (this._map) {
this._map.remove();
this._mapStore.set(null);
const map = get(this._map);
if (map) {
map.remove();
this._map.set(null);
}
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
this._unsubscribes = [];
}
resize() {
if (this._map) {
const map = get(this._map);
if (map) {
tick().then(() => {
this._map?.resize();
map.resize();
});
}
}
toggle3D() {
if (this._map) {
if (this._map.getPitch() === 0) {
this._map.easeTo({ pitch: 70 });
const map = get(this._map);
if (map) {
if (map.getPitch() === 0) {
map.easeTo({ pitch: 70 });
} else {
this._map.easeTo({ pitch: 0 });
map.easeTo({ pitch: 0 });
}
}
}
onLoad(callback: (map: maplibregl.Map) => void) {
if (this._map) {
callback(this._map);
} else {
this._onLoadCallbacks.push(callback);
}
}
callOnLoad() {
if (this._map && this._map.getLayer(ANCHOR_LAYER_KEY.overlays)) {
this._onLoadCallbacks.forEach((callback) => callback(this._map!));
this._onLoadCallbacks = [];
this._map.off('style.load', this.callOnLoadBinded);
}
}
}
export const map = new MapLibreGLMap();
export const map = new MapboxGLMap();
@@ -20,14 +20,9 @@
let container: HTMLElement;
onMount(() => {
map.onLoad((map_: maplibregl.Map) => {
googleRedirect = new GoogleRedirect(map_);
mapillaryLayer = new MapillaryLayer(
map_,
map.layerEventManager!,
container,
mapillaryOpen
);
map.onLoad((map: mapboxgl.Map) => {
googleRedirect = new GoogleRedirect(map);
mapillaryLayer = new MapillaryLayer(map, container, mapillaryOpen);
});
});
@@ -53,7 +48,7 @@
<CustomControl class="w-[29px] h-[29px] shrink-0">
<ButtonWithTooltip
variant="ghost"
class="w-full h-full border-none rounded-sm"
class="w-full h-full"
side="left"
label={i18n._('menu.toggle_street_view')}
onclick={() => {
@@ -1,10 +1,11 @@
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import type mapboxgl from 'mapbox-gl';
export class GoogleRedirect {
map: maplibregl.Map;
map: mapboxgl.Map;
enabled = false;
constructor(map: maplibregl.Map) {
constructor(map: mapboxgl.Map) {
this.map = map;
}
@@ -24,7 +25,7 @@ export class GoogleRedirect {
this.map.off('click', this.openStreetView);
}
openStreetView(e: maplibregl.MapMouseEvent) {
openStreetView(e: mapboxgl.MapMouseEvent) {
window.open(
`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${e.lngLat.lat},${e.lngLat.lng}`
);
@@ -1,9 +1,7 @@
import maplibregl, { type LayerSpecification, type VectorSourceSpecification } from 'maplibre-gl';
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 { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
const mapillarySource: VectorSourceSpecification = {
type: 'vector',
@@ -43,9 +41,8 @@ const mapillaryImageLayer: LayerSpecification = {
};
export class MapillaryLayer {
map: maplibregl.Map;
layerEventManager: MapLayerEventManager;
marker: maplibregl.Marker;
map: mapboxgl.Map;
marker: mapboxgl.Marker;
viewer: Viewer;
active = false;
@@ -55,14 +52,8 @@ export class MapillaryLayer {
onMouseEnterBinded = this.onMouseEnter.bind(this);
onMouseLeaveBinded = this.onMouseLeave.bind(this);
constructor(
map: maplibregl.Map,
layerEventManager: MapLayerEventManager,
container: HTMLElement,
popupOpen: { value: boolean }
) {
constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: { value: boolean }) {
this.map = map;
this.layerEventManager = layerEventManager;
this.viewer = new Viewer({
accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011',
@@ -70,12 +61,15 @@ export class MapillaryLayer {
});
const element = document.createElement('div');
element.className = 'maplibregl-user-location maplibregl-user-location-show-heading';
element.className = 'mapboxgl-user-location mapboxgl-user-location-show-heading';
const dot = document.createElement('div');
dot.className = 'maplibregl-user-location-dot';
dot.className = 'mapboxgl-user-location-dot';
const heading = document.createElement('div');
heading.className = 'mapboxgl-user-location-heading';
element.appendChild(dot);
element.appendChild(heading);
this.marker = new maplibregl.Marker({
this.marker = new mapboxgl.Marker({
rotationAlignment: 'map',
element,
});
@@ -105,20 +99,20 @@ export class MapillaryLayer {
this.map.addSource('mapillary', mapillarySource);
}
if (!this.map.getLayer('mapillary-sequence')) {
this.map.addLayer(mapillarySequenceLayer, ANCHOR_LAYER_KEY.mapillary);
this.map.addLayer(mapillarySequenceLayer);
}
if (!this.map.getLayer('mapillary-image')) {
this.map.addLayer(mapillaryImageLayer, ANCHOR_LAYER_KEY.mapillary);
this.map.addLayer(mapillaryImageLayer);
}
this.map.on('style.load', this.addBinded);
this.layerEventManager.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
this.layerEventManager.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
this.map.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
this.map.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
}
remove() {
this.map.off('style.load', this.addBinded);
this.layerEventManager.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
this.layerEventManager.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
this.map.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
this.map.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
if (this.map.getLayer('mapillary-image')) {
this.map.removeLayer('mapillary-image');
@@ -140,7 +134,7 @@ export class MapillaryLayer {
this.popupOpen.value = false;
}
onMouseEnter(e: maplibregl.MapLayerMouseEvent) {
onMouseEnter(e: mapboxgl.MapMouseEvent) {
if (
e.features &&
e.features.length > 0 &&
-263
View File
@@ -1,263 +0,0 @@
import { settings } from '$lib/logic/settings';
import { get, type Writable } from 'svelte/store';
import {
basemaps,
defaultBasemap,
maptilerKeyPlaceHolder,
overlays,
terrainSources,
} from '$lib/assets/layers';
import { getLayers } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n.svelte';
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } =
settings;
const emptySource: maplibregl.GeoJSONSourceSpecification = {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [],
},
};
export const ANCHOR_LAYER_KEY = {
overlays: 'overlays-end',
mapillary: 'mapillary-end',
tracks: 'tracks-end',
directionMarkers: 'direction-markers-end',
distanceMarkers: 'distance-markers-end',
startEndMarkers: 'start-end-markers-end',
interactions: 'interactions-end',
overpass: 'overpass-end',
waypoints: 'waypoints-end',
routingControls: 'routing-controls-end',
};
const anchorLayers: maplibregl.LayerSpecification[] = Object.values(ANCHOR_LAYER_KEY).map((id) => ({
id: id,
type: 'symbol',
source: 'empty-source',
}));
export class StyleManager {
private _map: Writable<maplibregl.Map | null>;
private _maptilerKey: string;
private _pastOverlays: Set<string> = new Set();
constructor(map: Writable<maplibregl.Map | null>, maptilerKey: string) {
this._map = map;
this._maptilerKey = maptilerKey;
this._map.subscribe((map_) => {
if (map_) {
this.updateBasemap();
map_.on('style.load', () => this.updateOverlays());
map_.on('pitch', () => this.updateTerrain());
}
});
currentBasemap.subscribe(() => this.updateBasemap());
currentOverlays.subscribe(() => this.updateOverlays());
opacities.subscribe(() => this.updateOverlays());
terrainSource.subscribe(() => this.updateTerrain());
customLayers.subscribe(() => this.updateBasemap());
distanceUnits.subscribe(() => {
if (get(currentBasemap) === 'topo') this.updateBasemap();
});
}
updateBasemap() {
const map_ = get(this._map);
if (!map_) return;
let basemap = get(currentBasemap);
this.buildStyle(basemap).then((style) => {
if (get(currentBasemap) === basemap) map_.setStyle(style);
});
}
async buildStyle(basemap: string): Promise<maplibregl.StyleSpecification> {
const custom = get(customLayers);
const style: maplibregl.StyleSpecification = {
version: 8,
projection: {
type: 'globe',
},
sources: {
'empty-source': emptySource,
},
layers: [],
};
const basemapInfo = basemaps[basemap] ?? custom[basemap]?.value ?? basemaps[defaultBasemap];
let basemapStyle = basemaps.openStreetMap as maplibregl.StyleSpecification;
try {
basemapStyle = await this.get(basemapInfo);
for (const source in basemapStyle.sources) {
const src = basemapStyle.sources[source];
if (
src &&
typeof src === 'object' &&
'url' in src &&
typeof src.url === 'string' &&
src.url.includes(maptilerKeyPlaceHolder)
) {
src.url = src.url.replace(maptilerKeyPlaceHolder, this._maptilerKey);
}
}
} catch (e) {
console.error(e.message);
}
this.merge(style, basemapStyle);
const terrain = this.getCurrentTerrain();
style.sources[terrain.source] = terrainSources[terrain.source];
style.terrain = terrain.exaggeration > 0 ? terrain : undefined;
style.layers.push(...anchorLayers);
return style;
}
async updateOverlays() {
const map_ = get(this._map);
if (!map_) return;
if (!map_.getSource('empty-source')) return;
const custom = get(customLayers);
const overlayOpacities = get(opacities);
try {
const layers = getLayers(get(currentOverlays) ?? {});
for (let overlay in layers) {
if (!layers[overlay]) {
if (this._pastOverlays.has(overlay)) {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
try {
const overlayStyle = await this.get(overlayInfo);
for (let layer of overlayStyle.layers ?? []) {
if (map_.getLayer(layer.id)) {
map_.removeLayer(layer.id);
}
}
} catch (e) {
// Should not happen
}
this._pastOverlays.delete(overlay);
}
} else {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
try {
const overlayStyle = await this.get(overlayInfo);
const opacity = overlayOpacities[overlay];
for (let sourceId in overlayStyle.sources) {
if (!map_.getSource(sourceId)) {
map_.addSource(sourceId, overlayStyle.sources[sourceId]);
}
}
for (let layer of overlayStyle.layers ?? []) {
if (!map_.getLayer(layer.id)) {
if (opacity !== undefined) {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = opacity;
} else if (layer.type === 'hillshade') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['hillshade-exaggeration'] = opacity / 2;
}
}
map_.addLayer(layer, ANCHOR_LAYER_KEY.overlays);
}
}
this._pastOverlays.add(overlay);
} catch (e) {
console.error(e.message);
}
}
}
} catch (e) {}
}
updateTerrain() {
const map_ = get(this._map);
if (!map_) return;
const mapTerrain = map_.getTerrain();
const terrain = this.getCurrentTerrain();
if (JSON.stringify(mapTerrain) !== JSON.stringify(terrain)) {
if (terrain.exaggeration > 0) {
if (!map_.getSource(terrain.source)) {
map_.addSource(terrain.source, terrainSources[terrain.source]);
}
map_.setTerrain(terrain);
} else {
map_.setTerrain(null);
}
}
}
async get(
styleInfo: maplibregl.StyleSpecification | string
): Promise<maplibregl.StyleSpecification> {
if (typeof styleInfo === 'string') {
let styleUrl = styleInfo as string;
const response = await fetch(styleUrl, { cache: 'force-cache' });
if (!response.ok) {
throw new Error(`HTTP error fetching style "${styleInfo}": ${response.status}`);
}
const style = await response.json();
return style;
} else {
return styleInfo;
}
}
merge(style: maplibregl.StyleSpecification, other: maplibregl.StyleSpecification) {
style.sources = { ...style.sources, ...other.sources };
const units = get(distanceUnits);
for (let layer of other.layers ?? []) {
if ('source' in layer) {
if (layer.source == 'contours_m' && units === 'imperial') continue;
if (layer.source == 'contours_ft' && units !== 'imperial') continue;
}
if (layer.type === 'symbol' && layer.layout && layer.layout['text-field']) {
const textField = layer.layout['text-field'];
if (
Array.isArray(textField) &&
textField.length == 4 &&
Array.isArray(textField[3]) &&
textField[3][0] === 'coalesce' &&
Array.isArray(textField[3][1]) &&
textField[3][1][0] === 'get' &&
typeof textField[3][1][1] === 'string' &&
textField[3][1][1].startsWith('name')
) {
layer.layout['text-field'] = [
'coalesce',
['get', `name:${i18n.lang}`],
['get', 'name'],
];
}
}
style.layers.push(layer);
}
if (other.sprite && !style.sprite) {
style.sprite = other.sprite;
}
if (other.glyphs && !style.glyphs) {
style.glyphs = other.glyphs;
}
}
getCurrentTerrain() {
const terrain = get(terrainSource);
const map_ = get(this._map);
return {
source: terrain,
exaggeration: !map_ || map_.getPitch() === 0 ? 0 : 1,
};
}
}
@@ -11,7 +11,7 @@
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
import Reduce from '$lib/components/toolbar/tools/reduce/Reduce.svelte';
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
import maplibregl from 'maplibre-gl';
import mapboxgl from 'mapbox-gl';
import { settings } from '$lib/logic/settings';
let {
@@ -23,11 +23,11 @@
const { minimizeRoutingMenu } = settings;
let popupElement: HTMLDivElement | undefined = $state(undefined);
let popup: maplibregl.Popup | undefined = $derived.by(() => {
let popup: mapboxgl.Popup | undefined = $derived.by(() => {
if (!popupElement) {
return undefined;
}
let popup = new maplibregl.Popup({
let popup = new mapboxgl.Popup({
closeButton: false,
maxWidth: undefined,
});
@@ -16,11 +16,10 @@
import { getURLForLanguage } from '$lib/utils';
import { Trash2 } from '@lucide/svelte';
import { map } from '$lib/components/map/map';
import type { GeoJSONSource } from 'maplibre-gl';
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';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
let props: {
class?: string;
@@ -29,7 +28,7 @@
let cleanType = $state(CleanType.INSIDE);
let deleteTrackpoints = $state(true);
let deleteWaypoints = $state(true);
let rectangleCoordinates: maplibregl.LngLat[] = $state([]);
let rectangleCoordinates: mapboxgl.LngLat[] = $state([]);
$effect(() => {
if ($map) {
@@ -64,18 +63,15 @@
});
}
if (!$map.getLayer('rectangle')) {
$map.addLayer(
{
id: 'rectangle',
type: 'fill',
source: 'rectangle',
paint: {
'fill-color': 'SteelBlue',
'fill-opacity': 0.5,
},
$map.addLayer({
id: 'rectangle',
type: 'fill',
source: 'rectangle',
paint: {
'fill-color': 'SteelBlue',
'fill-opacity': 0.5,
},
ANCHOR_LAYER_KEY.interactions
);
});
}
}
}
@@ -2,6 +2,7 @@
import { Button } from '$lib/components/ui/button';
import Help from '$lib/components/Help.svelte';
import { MountainSnow } from '@lucide/svelte';
import { map } from '$lib/components/map/map';
import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection';
@@ -17,9 +18,13 @@
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
<Button
variant="outline"
class="whitespace-normal h-fit min-h-8 py-1"
class="whitespace-normal h-fit"
disabled={!validSelection}
onclick={() => fileActions.addElevationToSelection()}
onclick={() => {
if ($map) {
fileActions.addElevationToSelection($map);
}
}}
>
<MountainSnow size="16" class="shrink-0" />
{i18n._('toolbar.elevation.button')}
@@ -76,7 +76,7 @@
{/if}
<Button
variant="outline"
class="whitespace-normal h-fit min-h-8 py-1"
class="whitespace-normal h-fit"
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
onclick={() => {
@@ -38,7 +38,7 @@
let endTime: string | undefined = $state(undefined);
let movingTime: number | undefined = $state(undefined);
let speed: number | undefined = $state(undefined);
let artificial = $state(true);
let artificial = $state(false);
function toCalendarDate(date: Date): CalendarDate {
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
@@ -185,8 +185,8 @@
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
<fieldset class="flex flex-col gap-2">
<div class="flex flex-row gap-1.5 justify-center">
<div class="flex flex-col gap-1 grow">
<div class="flex flex-row gap-2 justify-center">
<div class="flex flex-col gap-2 grow">
<Label for="speed" class="flex flex-row">
<Zap size="16" />
{#if $velocityUnits === 'speed'}
@@ -239,7 +239,7 @@
{/if}
</div>
</div>
<div class="flex flex-col gap-1 grow">
<div class="flex flex-col gap-2 grow">
<Label for="duration" class="flex flex-row">
<Timer size="16" />
{i18n._('toolbar.time.total_time')}
@@ -253,61 +253,57 @@
/>
</div>
</div>
<div class="flex flex-col gap-1">
<Label class="flex flex-row">
<CirclePlay size="16" />
{i18n._('toolbar.time.start')}
</Label>
<div class="flex flex-row gap-1.5">
<DatePicker
bind:value={startDate}
disabled={!canUpdate}
locale={i18n.lang}
placeholder={i18n._('toolbar.time.pick_date')}
class="w-fit grow"
onchange={() => {
untrack(() => updateEnd());
}}
/>
<Input
type="time"
step={1}
disabled={!canUpdate}
bind:value={startTime}
class="w-fit"
onchange={() => {
untrack(() => updateEnd());
}}
/>
</div>
<Label class="flex flex-row">
<CirclePlay size="16" />
{i18n._('toolbar.time.start')}
</Label>
<div class="flex flex-row gap-2">
<DatePicker
bind:value={startDate}
disabled={!canUpdate}
locale={i18n.lang}
placeholder={i18n._('toolbar.time.pick_date')}
class="w-fit grow"
onchange={() => {
untrack(() => updateEnd());
}}
/>
<Input
type="time"
step={1}
disabled={!canUpdate}
bind:value={startTime}
class="w-fit"
onchange={() => {
untrack(() => updateEnd());
}}
/>
</div>
<div class="flex flex-col gap-1">
<Label class="flex flex-row">
<CircleStop size="16" />
{i18n._('toolbar.time.end')}
</Label>
<div class="flex flex-row gap-1.5">
<DatePicker
bind:value={endDate}
disabled={!canUpdate}
locale={i18n.lang}
placeholder={i18n._('toolbar.time.pick_date')}
class="w-fit grow"
onchange={() => {
untrack(() => updateStart());
}}
/>
<Input
type="time"
step={1}
disabled={!canUpdate}
bind:value={endTime}
class="w-fit"
onchange={() => {
untrack(() => updateStart());
}}
/>
</div>
<Label class="flex flex-row">
<CircleStop size="16" />
{i18n._('toolbar.time.end')}
</Label>
<div class="flex flex-row gap-2">
<DatePicker
bind:value={endDate}
disabled={!canUpdate}
locale={i18n.lang}
placeholder={i18n._('toolbar.time.pick_date')}
class="w-fit grow"
onchange={() => {
untrack(() => updateStart());
}}
/>
<Input
type="time"
step={1}
disabled={!canUpdate}
bind:value={endTime}
class="w-fit"
onchange={() => {
untrack(() => updateStart());
}}
/>
</div>
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
<div class="mt-0.5 flex flex-row gap-1 items-center">
@@ -318,11 +314,11 @@
</div>
{/if}
</fieldset>
<div class="flex flex-row gap-1.5 items-center">
<div class="flex flex-row gap-2 items-center">
<Button
variant="outline"
disabled={!canUpdate}
class="grow shrink whitespace-normal h-fit min-h-8 py-1"
class="grow whitespace-normal h-fit"
onclick={() => {
let effectiveSpeed = getSpeed();
if (
@@ -350,7 +346,7 @@
let fileId = item.getFileId();
fileActionManager.applyToFile(fileId, (file) => {
if (item instanceof ListFileItem) {
if (artificial && !$gpxStatistics.global.time.moving) {
if (artificial || !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps(
getDate(startDate!, startTime!),
movingTime!
@@ -363,7 +359,7 @@
);
}
} else if (item instanceof ListTrackItem) {
if (artificial && !$gpxStatistics.global.time.moving) {
if (artificial || !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps(
getDate(startDate!, startTime!),
movingTime!,
@@ -378,7 +374,7 @@
);
}
} else if (item instanceof ListTrackSegmentItem) {
if (artificial && !$gpxStatistics.global.time.moving) {
if (artificial || !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps(
getDate(startDate!, startTime!),
movingTime!,
@@ -10,11 +10,11 @@
import { onDestroy } from 'svelte';
import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection';
import { minTolerance, ReducedGPXLayerCollection, tolerance } from './utils.svelte';
import { minTolerance, ReducedGPXLayerCollection, tolerance } from './reduce.svelte';
let props: { class?: string } = $props();
let sliderValue = $state(50);
let sliderValue = $state([50]);
const maxTolerance = 10000;
let validSelection = $derived(
@@ -25,7 +25,7 @@
$effect(() => {
tolerance.set(
minTolerance * 2 ** (sliderValue / (100 / Math.log2(maxTolerance / minTolerance)))
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)))
);
});
@@ -36,7 +36,7 @@
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
<div class="p-2">
<Slider bind:value={sliderValue} min={0} max={100} step={1} type="single" />
<Slider bind:value={sliderValue} min={0} max={100} step={1} type="multiple" />
</div>
<Label class="flex flex-row justify-between">
<span>{i18n._('toolbar.reduce.tolerance')}</span>
@@ -1,12 +1,11 @@
import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list';
import { map } from '$lib/components/map/map';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { fileActions } from '$lib/logic/file-actions';
import { GPXFileStateCollectionObserver, type GPXFileState } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection';
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
import type { GeoJSONSource } from 'maplibre-gl';
import { get, writable } from 'svelte/store';
import type { GeoJSONSource } from 'mapbox-gl';
import { get, writable, type Writable } from 'svelte/store';
export const minTolerance = 0.1;
@@ -29,15 +28,17 @@ export class ReducedGPXLayer {
update() {
const file = this._fileState.file;
if (!file) {
const stats = this._fileState.statistics;
if (!file || !stats) {
return;
}
file.forEachSegment((segment, trackIndex, segmentIndex) => {
let segmentItem = new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex);
let statistics = stats.getStatisticsFor(segmentItem);
this._updateSimplified(segmentItem.getFullId(), [
segmentItem,
segment.trkpt.length,
ramerDouglasPeucker(segment.trkpt, minTolerance),
statistics.local.points.length,
ramerDouglasPeucker(statistics.local.points, minTolerance),
]);
});
}
@@ -145,27 +146,23 @@ export class ReducedGPXLayerCollection {
});
}
if (!map_.getLayer('simplified')) {
map_.addLayer(
{
id: 'simplified',
type: 'line',
source: 'simplified',
paint: {
'line-color': 'white',
'line-width': 3,
},
map_.addLayer({
id: 'simplified',
type: 'line',
source: 'simplified',
paint: {
'line-color': 'white',
'line-width': 3,
},
ANCHOR_LAYER_KEY.interactions
);
});
} else {
map_.moveLayer('simplified');
}
}
reduce() {
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
this._simplified.forEach(([item, maxPts, points], itemFullId) => {
if (!get(selection).hasAnyParent(item)) {
return;
}
itemsAndPoints.set(
item,
points
@@ -21,7 +21,7 @@
SquareArrowUpLeft,
SquareArrowOutDownRight,
} from '@lucide/svelte';
import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing';
import { brouterProfiles } from '$lib/components/toolbar/tools/routing/routing';
import { i18n } from '$lib/i18n.svelte';
import { slide } from 'svelte/transition';
import {
@@ -51,7 +51,7 @@
}: {
minimized?: boolean;
minimizable?: boolean;
popup?: maplibregl.Popup;
popup?: mapboxgl.Popup;
popupElement?: HTMLDivElement;
class?: string;
} = $props();
@@ -163,11 +163,11 @@
{i18n._('toolbar.routing.activity')}
</span>
<Select.Root type="single" bind:value={$routingProfile}>
<Select.Trigger class="grow" size="sm">
<Select.Trigger class="h-8 grow">
{i18n._(`toolbar.routing.activities.${$routingProfile}`)}
</Select.Trigger>
<Select.Content>
{#each Object.keys(routingProfiles) as profile}
{#each Object.keys(brouterProfiles) as profile}
<Select.Item value={profile}
>{i18n._(
`toolbar.routing.activities.${profile}`
@@ -191,16 +191,16 @@
<ButtonWithTooltip
label={i18n._('toolbar.routing.reverse.tooltip')}
variant="outline"
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
class="gap-1 text-xs"
disabled={!validSelection}
onclick={fileActions.reverseSelection}
>
<ArrowRightLeft class="size-3" />{i18n._('toolbar.routing.reverse.button')}
<ArrowRightLeft size="12" />{i18n._('toolbar.routing.reverse.button')}
</ButtonWithTooltip>
<ButtonWithTooltip
label={i18n._('toolbar.routing.route_back_to_start.tooltip')}
variant="outline"
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
class="gap-1 text-xs"
disabled={!validSelection}
onclick={() => {
const selected = selection.getOrderedSelection();
@@ -231,19 +231,19 @@
}
}}
>
<House class="size-3" />{i18n._('toolbar.routing.route_back_to_start.button')}
<House size="12" />{i18n._('toolbar.routing.route_back_to_start.button')}
</ButtonWithTooltip>
<ButtonWithTooltip
label={i18n._('toolbar.routing.round_trip.tooltip')}
variant="outline"
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
class="gap-1 text-xs"
disabled={!validSelection}
onclick={fileActions.createRoundTripForSelection}
>
<Repeat class="size-3" />{i18n._('toolbar.routing.round_trip.button')}
<Repeat size="12" />{i18n._('toolbar.routing.round_trip.button')}
</ButtonWithTooltip>
</div>
<div class="w-full flex flex-row gap-1 items-end justify-between">
<div class="w-full flex flex-row gap-2 items-end justify-between">
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/routing')}>
{#if !validSelection}
{i18n._('toolbar.routing.help_no_file')}
File diff suppressed because it is too large Load Diff
@@ -6,213 +6,37 @@ import { get } from 'svelte/store';
const { routing, routingProfile, privateRoads } = settings;
export type RoutingProfile = {
engine: 'graphhopper' | 'brouter';
profile: string;
};
export const routingProfiles: { [key: string]: RoutingProfile } = {
bike: { engine: 'graphhopper', profile: 'bike' },
racing_bike: { engine: 'graphhopper', profile: 'racingbike' },
gravel_bike: { engine: 'graphhopper', profile: 'gravelbike' },
mountain_bike: { engine: 'graphhopper', profile: 'mtb' },
foot: { engine: 'graphhopper', profile: 'foot' },
motorcycle: { engine: 'graphhopper', profile: 'motorbike' },
water: { engine: 'brouter', profile: 'river' },
railway: { engine: 'brouter', profile: 'rail' },
export const brouterProfiles: { [key: string]: string } = {
bike: 'Trekking-dry',
racing_bike: 'fastbike',
gravel_bike: 'gravel',
mountain_bike: 'MTB',
foot: 'Hiking-Alpine-SAC6',
motorcycle: 'Car-FastEco',
water: 'river',
railway: 'rail',
};
export function route(points: Coordinates[]): Promise<TrackPoint[]> {
if (get(routing)) {
const profile = routingProfiles[get(routingProfile)];
if (profile.engine === 'graphhopper') {
return getGraphHopperRoute(points, profile.profile, get(privateRoads));
} else {
return getBRouterRoute(points, profile.profile);
}
return getRoute(points, brouterProfiles[get(routingProfile)], get(privateRoads));
} else {
return getIntermediatePoints(points);
}
}
const graphhopperDetails = ['road_class', 'surface', 'hike_rating', 'mtb_rating'];
const hikeRatingToSACScale: { [key: string]: string } = {
'1': 'hiking',
'2': 'mountain_hiking',
'3': 'demanding_mountain_hiking',
'4': 'alpine_hiking',
'5': 'demanding_alpine_hiking',
'6': 'difficult_alpine_hiking',
};
const mtbRatingToScale: { [key: string]: string } = {
'1': '0',
'2': '1',
'3': '2',
'4': '3',
'5': '4',
'6': '5',
'7': '6',
};
const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
bike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
racingbike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
gravelbike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
mtb: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
foot: {
priority: [
{
if: 'foot_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
motorcycle: {
priority: [
{
if: 'road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
};
async function getGraphHopperRoute(
async function getRoute(
points: Coordinates[],
graphHopperProfile: string,
brouterProfile: string,
privateRoads: boolean
): Promise<TrackPoint[]> {
let response = await fetch('https://graphhopper.gpx.studio/route', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
points: points.map((point) => [point.lon, point.lat]),
profile: graphHopperProfile,
elevation: true,
points_encoded: false,
details: graphhopperDetails,
custom_model: privateRoads
? {}
: graphhopperBlockPrivateCustomModels[graphHopperProfile] || {},
}),
});
if (!response.ok) {
const error = await response.json();
if (error.message.includes('Cannot find point 0')) {
throw new Error('toolbar.routing.error.from');
} else if (error.message.includes('Cannot find point 1')) {
if (points.length == 3) {
throw new Error('toolbar.routing.error.via');
} else {
throw new Error('toolbar.routing.error.to');
}
} else if (error.hints[0].details.includes('PointDistanceExceededException')) {
throw new Error('toolbar.routing.error.distance');
} else if (error.hints[0].details.includes('ConnectionNotFoundException')) {
throw new Error('toolbar.routing.error.connection');
} else {
throw new Error(error.message);
}
}
let json = await response.json();
let route: TrackPoint[] = [];
let coordinates = json.paths[0].points.coordinates;
let details = json.paths[0].details;
for (let i = 0; i < coordinates.length; i++) {
route.push(
new TrackPoint({
attributes: {
lat: coordinates[i][1],
lon: coordinates[i][0],
},
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
extensions: {},
})
);
}
for (let key of graphhopperDetails) {
let detail = details[key];
for (let i = 0; i < detail.length; i++) {
for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1); j++) {
if (detail[i][2] !== undefined && detail[i][2] !== 'missing') {
if (key === 'road_class') {
route[j].setExtension('highway', detail[i][2]);
} else if (key === 'hike_rating') {
const sacScale = hikeRatingToSACScale[detail[i][2]];
if (sacScale) {
route[j].setExtension('sac_scale', sacScale);
}
} else if (key === 'mtb_rating') {
const mtbScale = mtbRatingToScale[detail[i][2]];
if (mtbScale) {
route[j].setExtension('mtb_scale', mtbScale);
}
} else if (key === 'surface' && detail[i][2] !== 'other') {
route[j].setExtension('surface', detail[i][2]);
}
}
}
}
}
return route;
}
async function getBRouterRoute(
points: Coordinates[],
brouterProfile: string
): Promise<TrackPoint[]> {
let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
let url = `https://brouter.gpx.studio?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`;
let response = await fetch(url);
// Check if the response is ok
if (!response.ok) {
const error = await response.text();
if (error.includes('from-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.from');
} else if (error.includes('via1-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.via');
} else if (error.includes('to-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.to');
} else if (error.includes('Time-out')) {
throw new Error('toolbar.routing.error.timeout');
} else {
throw new Error(error);
}
throw new Error(`${await response.text()}`);
}
let geojson = await response.json();
@@ -228,13 +52,14 @@ async function getBRouterRoute(
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
for (let i = 0; i < coordinates.length; i++) {
let coord = coordinates[i];
route.push(
new TrackPoint({
attributes: {
lat: coordinates[i][1],
lon: coordinates[i][0],
lat: coord[1],
lon: coord[0],
},
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0),
})
);
@@ -2,21 +2,15 @@ import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
const earthRadius = 6371008.8;
export const MIN_ANCHOR_ZOOM = 0;
export const MAX_ANCHOR_ZOOM = 22;
export function getZoomLevelForDistance(latitude: number, distance?: number): number {
if (distance === undefined) {
return MIN_ANCHOR_ZOOM;
return 0;
}
const rad = Math.PI / 180;
const lat = latitude * rad;
return Math.min(
MAX_ANCHOR_ZOOM,
Math.max(MIN_ANCHOR_ZOOM, Math.round(Math.log2((earthRadius * Math.cos(lat)) / distance)))
);
return Math.min(22, Math.max(0, Math.log2((earthRadius * Math.cos(lat)) / distance)));
}
export function updateAnchorPoints(file: GPXFile) {
@@ -26,24 +26,26 @@
let validSelection = $derived(
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
$gpxStatistics.global.length > 0
$gpxStatistics.local.points.length > 0
);
let maxSliderValue = $derived(
validSelection && $gpxStatistics.global.length > 0 ? $gpxStatistics.global.length - 1 : 1
validSelection && $gpxStatistics.local.points.length > 0
? $gpxStatistics.local.points.length - 1
: 1
);
let sliderValues = $derived([0, maxSliderValue]);
let canCrop = $derived(sliderValues[0] != 0 || sliderValues[1] != maxSliderValue);
onMount(() => {
if ($map) {
splitControls = new SplitControls($map, map.layerEventManager!);
splitControls = new SplitControls($map);
}
});
function updateSlicedGPXStatistics() {
if (validSelection && canCrop) {
$slicedGPXStatistics = [
get(gpxStatistics).sliced(sliderValues[0], sliderValues[1]),
get(gpxStatistics).slice(sliderValues[0], sliderValues[1]),
sliderValues[0],
sliderValues[1],
];
@@ -105,7 +107,7 @@
{i18n._('toolbar.scissors.split_as')}
</span>
<Select.Root bind:value={$splitAs} type="single">
<Select.Trigger class="w-fit grow" size="sm">
<Select.Trigger class="h-8 w-fit grow">
{i18n._('gpx.' + $splitAs)}
</Select.Trigger>
<Select.Content>
@@ -1,3 +1,5 @@
import { TrackPoint, TrackSegment } from 'gpx';
import mapboxgl from 'mapbox-gl';
import { ListTrackSegmentItem } from '$lib/components/file-list/file-list';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
@@ -7,34 +9,19 @@ import { gpxStatistics } from '$lib/logic/statistics';
import { get } from 'svelte/store';
import { fileStateCollection } from '$lib/logic/file-state';
import { fileActions } from '$lib/logic/file-actions';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
export class SplitControls {
map: maplibregl.Map;
layerEventManager: MapLayerEventManager;
active: boolean = false;
map: mapboxgl.Map;
controls: ControlWithMarker[] = [];
shownControls: ControlWithMarker[] = [];
unsubscribes: Function[] = [];
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
toggleControlsForZoomLevelAndBoundsBinded: () => void =
this.toggleControlsForZoomLevelAndBounds.bind(this);
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
constructor(map: mapboxgl.Map) {
this.map = map;
this.layerEventManager = layerEventManager;
loadSVGIcon(
this.map,
'split-control',
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="white" />
<g transform="translate(8 8)">
${Scissors.replace('stroke="currentColor"', 'stroke="black"')}
</g>
</svg>`
);
this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
@@ -44,18 +31,29 @@ export class SplitControls {
addIfNeeded() {
let scissors = get(currentTool) === Tool.SCISSORS;
if (!scissors) {
this.remove();
if (this.active) {
this.remove();
}
return;
}
this.updateControls();
if (this.active) {
this.updateControls();
} else {
this.add();
}
}
add() {
this.active = true;
this.map.on('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
this.map.on('move', this.toggleControlsForZoomLevelAndBoundsBinded);
}
updateControls() {
let data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
};
// Update the markers when the files change
let controlIndex = 0;
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = fileStateCollection.getFile(fileId);
@@ -66,23 +64,30 @@ export class SplitControls {
new ListTrackSegmentItem(fileId, trackIndex, segmentIndex)
)
) {
for (let i = 1; i < segment.trkpt.length - 1; i++) {
let point = segment.trkpt[i];
for (let point of segment.trkpt.slice(1, -1)) {
// Update the existing controls (could be improved by matching the existing controls with the new ones?)
if (point._data.anchor) {
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [point.getLongitude(), point.getLatitude()],
},
properties: {
fileId: fileId,
trackIndex: trackIndex,
segmentIndex: segmentIndex,
pointIndex: i,
minZoom: point._data.zoom,
},
});
if (controlIndex < this.controls.length) {
this.controls[controlIndex].fileId = fileId;
this.controls[controlIndex].point = point;
this.controls[controlIndex].segment = segment;
this.controls[controlIndex].trackIndex = trackIndex;
this.controls[controlIndex].segmentIndex = segmentIndex;
this.controls[controlIndex].marker.setLngLat(
point.getCoordinates()
);
} else {
this.controls.push(
this.createControl(
point,
segment,
fileId,
trackIndex,
segmentIndex
)
);
}
controlIndex++;
}
}
}
@@ -90,86 +95,86 @@ export class SplitControls {
}
}, false);
try {
let source = this.map.getSource('split-controls') as GeoJSONSource | undefined;
if (source) {
source.setData(data);
} else {
this.map.addSource('split-controls', {
type: 'geojson',
data: data,
});
}
if (!this.map.getLayer('split-controls')) {
this.map.addLayer(
{
id: 'split-controls',
type: 'symbol',
source: 'split-controls',
layout: {
'icon-image': 'split-control',
'icon-size': 0.25,
'icon-padding': 0,
},
filter: ['<=', ['get', 'minZoom'], ['zoom']],
},
ANCHOR_LAYER_KEY.interactions
);
this.layerEventManager.on(
'mouseenter',
'split-controls',
this.layerOnMouseEnterBinded
);
this.layerEventManager.on(
'mouseleave',
'split-controls',
this.layerOnMouseLeaveBinded
);
this.layerEventManager.on('click', 'split-controls', this.layerOnClickBinded);
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
while (controlIndex < this.controls.length) {
// Remove the extra controls
this.controls.pop()?.marker.remove();
}
this.toggleControlsForZoomLevelAndBounds();
}
remove() {
this.layerEventManager.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
this.layerEventManager.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
this.layerEventManager.off('click', 'split-controls', this.layerOnClickBinded);
this.active = false;
try {
if (this.map.getLayer('split-controls')) {
this.map.removeLayer('split-controls');
}
if (this.map.getSource('split-controls')) {
this.map.removeSource('split-controls');
}
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
for (let control of this.controls) {
control.marker.remove();
}
this.map.off('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
this.map.off('move', this.toggleControlsForZoomLevelAndBoundsBinded);
}
layerOnMouseEnter(e: any) {
mapCursor.notify(MapCursorState.SPLIT_CONTROL, true);
toggleControlsForZoomLevelAndBounds() {
// Show markers only if they are in the current zoom level and bounds
this.shownControls.splice(0, this.shownControls.length);
let southWest = this.map.unproject([0, this.map.getCanvas().height]);
let northEast = this.map.unproject([this.map.getCanvas().width, 0]);
let bounds = new mapboxgl.LngLatBounds(southWest, northEast);
let zoom = this.map.getZoom();
this.controls.forEach((control) => {
control.inZoom = control.point._data.zoom <= zoom;
if (control.inZoom && bounds.contains(control.marker.getLngLat())) {
control.marker.addTo(this.map);
this.shownControls.push(control);
} else {
control.marker.remove();
}
});
}
layerOnMouseLeave() {
mapCursor.notify(MapCursorState.SPLIT_CONTROL, false);
}
createControl(
point: TrackPoint,
segment: TrackSegment,
fileId: string,
trackIndex: number,
segmentIndex: number
): ControlWithMarker {
let element = document.createElement('div');
element.className = `h-6 w-6 p-0.5 rounded-full bg-white border-2 border-black cursor-pointer`;
element.innerHTML = Scissors.replace('width="24"', '')
.replace('height="24"', '')
.replace('stroke="currentColor"', 'stroke="black"');
layerOnClick(e: maplibregl.MapLayerMouseEvent) {
let coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates;
fileActions.split(
get(splitAs),
e.features![0].properties!.fileId,
e.features![0].properties!.trackIndex,
e.features![0].properties!.segmentIndex,
{ lon: coordinates[0], lat: coordinates[1] },
e.features![0].properties!.pointIndex
);
let marker = new mapboxgl.Marker({
draggable: true,
className: 'z-10',
element,
}).setLngLat(point.getCoordinates());
let control = {
point,
segment,
fileId,
trackIndex,
segmentIndex,
marker,
inZoom: false,
};
marker.getElement().addEventListener('click', (e) => {
e.stopPropagation();
fileActions.split(
get(splitAs),
control.fileId,
control.trackIndex,
control.segmentIndex,
control.point.getCoordinates(),
control.point._data.index
);
});
return control;
}
destroy() {
@@ -177,3 +182,16 @@ export class SplitControls {
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
}
}
type Control = {
segment: TrackSegment;
fileId: string;
trackIndex: number;
segmentIndex: number;
point: TrackPoint;
};
type ControlWithMarker = Control & {
marker: mapboxgl.Marker;
inZoom: boolean;
};
@@ -16,8 +16,6 @@
import { fileActions } from '$lib/logic/file-actions';
import { map } from '$lib/components/map/map';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import maplibregl from 'maplibre-gl';
import { getSvgForSymbol } from '$lib/components/map/gpx-layer/gpx-layer';
let props: {
class?: string;
@@ -41,21 +39,6 @@
})
);
let marker: maplibregl.Marker | null = null;
function reset() {
if ($selectedWaypoint) {
selectedWaypoint.reset();
} else {
name = '';
description = '';
link = '';
sym = '';
longitude = 0;
latitude = 0;
}
}
$effect(() => {
if ($selectedWaypoint) {
const wpt = $selectedWaypoint[0];
@@ -71,7 +54,14 @@
latitude = parseFloat(wpt.getLatitude().toFixed(6));
});
} else {
untrack(reset);
untrack(() => {
name = '';
description = '';
link = '';
sym = '';
longitude = 0;
latitude = 0;
});
}
});
@@ -95,14 +85,14 @@
desc: description.length > 0 ? description : undefined,
cmt: description.length > 0 ? description : undefined,
link: link.length > 0 ? { attributes: { href: link } } : undefined,
sym: sym.length > 0 ? sym : undefined,
sym: sym,
},
selectedWaypoint.wpt && selectedWaypoint.fileId
? new ListWaypointItem(selectedWaypoint.fileId, selectedWaypoint.wpt._data.index)
: undefined
);
reset();
selectedWaypoint.reset();
}
function setCoordinates(e: any) {
@@ -110,37 +100,6 @@
longitude = e.lngLat.lng.toFixed(6);
}
$effect(() => {
if ($selectedWaypoint) {
if (marker) {
marker.remove();
marker = null;
}
} else if (latitude != 0 || longitude != 0) {
if ($map) {
if (marker) {
marker.setLngLat([longitude, latitude]).getElement().innerHTML =
getSvgForSymbol(symbolKey);
} else {
let element = document.createElement('div');
element.classList.add('w-8', 'h-8');
element.innerHTML = getSvgForSymbol(symbolKey);
marker = new maplibregl.Marker({
element,
anchor: 'bottom',
})
.setLngLat([longitude, latitude])
.addTo($map);
}
}
} else {
if (marker) {
marker.remove();
marker = null;
}
}
});
onMount(() => {
if ($map) {
$map.on('click', setCoordinates);
@@ -153,81 +112,62 @@
$map.off('click', setCoordinates);
mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, false);
}
if (marker) {
marker.remove();
marker = null;
}
});
</script>
<div class="flex flex-col gap-3 w-full max-w-96 {props.class ?? ''}">
<fieldset class="flex flex-col gap-1.5">
<div class="flex flex-col gap-1">
<Label for="name">{i18n._('menu.metadata.name')}</Label>
<Input
bind:value={name}
id="name"
class="font-semibold"
<fieldset class="flex flex-col gap-2">
<Label for="name">{i18n._('menu.metadata.name')}</Label>
<Input
bind:value={name}
id="name"
class="font-semibold h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
<Label for="description">{i18n._('menu.metadata.description')}</Label>
<Textarea
bind:value={description}
id="description"
disabled={!canCreate && !$selectedWaypoint}
/>
<Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label>
<Select.Root bind:value={sym} type="single">
<Select.Trigger
id="symbol"
class="w-full h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
</div>
<div class="flex flex-col gap-1">
<Label for="description">{i18n._('menu.metadata.description')}</Label>
<Textarea
bind:value={description}
id="description"
disabled={!canCreate && !$selectedWaypoint}
class="min-h-8 h-8 py-1 px-3 text-sm"
/>
</div>
<div class="flex flex-col gap-1">
<Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label>
<Select.Root bind:value={sym} type="single">
<Select.Trigger
id="symbol"
class="w-full"
disabled={!canCreate && !$selectedWaypoint}
>
<span class="flex flex-row gap-1.5 items-center">
{#if symbolKey}
{#if symbols[symbolKey].icon}
{@const Component = symbols[symbolKey].icon}
<Component size="14" />
>
{#if symbolKey}
{i18n._(`gpx.symbol.${symbolKey}`)}
{:else}
{sym}
{/if}
</Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll">
{#each sortedSymbols as [key, symbol]}
<Select.Item value={symbol.value}>
<span>
{#if symbol.icon}
{@const Component = symbol.icon}
<Component size="14" class="inline-block align-sub mr-0.5" />
{:else}
<span class="w-4 inline-block"></span>
{/if}
{i18n._(`gpx.symbol.${symbolKey}`)}
{:else}
{sym}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-60">
{#each sortedSymbols as [key, symbol]}
<Select.Item value={symbol.value}>
<span>
{#if symbol.icon}
{@const Component = symbol.icon}
<Component size="14" class="inline-block align-sub" />
{:else}
<span class="w-4 inline-block"></span>
{/if}
{i18n._(`gpx.symbol.${key}`)}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1">
<Label for="link">{i18n._('toolbar.waypoint.link')}</Label>
<Input
bind:value={link}
id="link"
class="h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
</div>
<div class="flex flex-row gap-1.5">
<div class="grow flex flex-col gap-1">
{i18n._(`gpx.symbol.${key}`)}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Label for="link">{i18n._('toolbar.waypoint.link')}</Label>
<Input
bind:value={link}
id="link"
class="h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-2">
<Label for="latitude">{i18n._('toolbar.waypoint.latitude')}</Label>
<Input
bind:value={latitude}
@@ -240,7 +180,7 @@
disabled={!canCreate && !$selectedWaypoint}
/>
</div>
<div class="grow flex flex-col gap-1">
<div class="grow flex flex-col gap-2">
<Label for="longitude">{i18n._('toolbar.waypoint.longitude')}</Label>
<Input
bind:value={longitude}
@@ -255,11 +195,11 @@
</div>
</div>
</fieldset>
<div class="flex flex-row gap-1.5 items-center">
<div class="flex flex-row gap-2 items-center">
<Button
variant="outline"
disabled={!canCreate && !$selectedWaypoint}
class="grow shrink h-fit min-h-8 whitespace-normal py-1"
class="grow whitespace-normal h-fit"
onclick={createOrUpdateWaypoint}
>
{#if $selectedWaypoint}
@@ -270,7 +210,7 @@
{i18n._('toolbar.waypoint.create')}
{/if}
</Button>
<Button variant="outline" size="icon" onclick={reset}>
<Button variant="outline" size="icon" onclick={() => selectedWaypoint.reset()}>
<CircleX size="16" />
</Button>
</div>
@@ -13,15 +13,10 @@
<AccordionPrimitive.Content
bind:ref
data-slot="accordion-content"
class="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden"
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...restProps}
>
<div
class={cn(
"pt-0 pb-2.5 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4",
className
)}
>
<div class={cn("pb-4 pt-0", className)}>
{@render children?.()}
</div>
</AccordionPrimitive.Content>
@@ -12,6 +12,6 @@
<AccordionPrimitive.Item
bind:ref
data-slot="accordion-item"
class={cn("not-last:border-b", className)}
class={cn("border-b last:border-b-0", className)}
{...restProps}
/>

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