mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-03-13 16:22:59 +00:00
Compare commits
648 Commits
1db9ecafef
...
maplibre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9ca75e2e8 | ||
|
|
091f6a3ed0 | ||
|
|
d6c9fb1025 | ||
|
|
88abd72a41 | ||
|
|
1137e851ce | ||
|
|
b8c1500aad | ||
|
|
bfd0d90abc | ||
|
|
dba01e1826 | ||
|
|
2189c76edd | ||
|
|
6f8c9d66db | ||
|
|
9408ce10c7 | ||
|
|
9895c3c304 | ||
|
|
0ab3b77db8 | ||
|
|
d13e7e7a0a | ||
|
|
e96b544a75 | ||
|
|
375204c379 | ||
|
|
d76c03af4f | ||
|
|
200a6586ba | ||
|
|
f0f1ecb2df | ||
|
|
2eb6ef6f03 | ||
|
|
f7c0805161 | ||
|
|
4e18e3c8a0 | ||
|
|
59f31caf26 | ||
|
|
f24956c58d | ||
|
|
9019317e5c | ||
|
|
2a0227c1de | ||
|
|
f70db42b91 | ||
|
|
9cd87742f0 | ||
|
|
5dcb93ca5d | ||
|
|
256d62b29b | ||
|
|
595ea8e2d3 | ||
|
|
d3e733aa3e | ||
|
|
a011768d2d | ||
|
|
4b45b5d716 | ||
|
|
ebe9681c12 | ||
|
|
51c85e4cd5 | ||
|
|
2e171dfbee | ||
|
|
a6a3917986 | ||
|
|
21f2448213 | ||
|
|
e7a1d0488b | ||
|
|
22b8e0edb4 | ||
|
|
d062a38e8f | ||
|
|
affa59130f | ||
|
|
3c816567bc | ||
|
|
e92e48ffde | ||
|
|
4ce7777b86 | ||
|
|
bc130ad867 | ||
|
|
867b6a6ac7 | ||
|
|
e585fd084c | ||
|
|
b47bb4a771 | ||
|
|
9cff71fed3 | ||
|
|
e76040e416 | ||
|
|
1facf50621 | ||
|
|
57f3cc8bc0 | ||
|
|
1ab3fe1c4a | ||
|
|
10cff632fd | ||
|
|
4105687c0a | ||
|
|
8fe6565527 | ||
|
|
69b018022d | ||
|
|
467cb2e589 | ||
|
|
f13d8c1e22 | ||
|
|
e230d55b82 | ||
|
|
46fcdb4bb2 | ||
|
|
429212ef23 | ||
|
|
4ea0ea6a7a | ||
|
|
2e3ce83605 | ||
|
|
fda908dd0d | ||
|
|
cad77e2b10 | ||
|
|
3542a7c24d | ||
|
|
0d6d161e23 | ||
|
|
89a2e0086b | ||
|
|
cd443faf61 | ||
|
|
bfc56b02a8 | ||
|
|
25bafc6bf1 | ||
|
|
6387580626 | ||
|
|
09b8aa65fc | ||
|
|
6c15193f32 | ||
|
|
4442e29b66 | ||
|
|
b6f96d9f4d | ||
|
|
36b66100f9 | ||
|
|
49d8143cc6 | ||
|
|
fc279fecaf | ||
|
|
bd307daa57 | ||
|
|
7a72f44722 | ||
|
|
8e63fc6946 | ||
|
|
3a65f8dc16 | ||
|
|
d624352a0b | ||
|
|
3fb597a774 | ||
|
|
85e46bc524 | ||
|
|
d3790878b3 | ||
|
|
e648e50f1b | ||
|
|
cb6cede0b6 | ||
|
|
a0157ddc2a | ||
|
|
8f595fbc7b | ||
|
|
2e3b22c5fa | ||
|
|
42b968372b | ||
|
|
415e7b492f | ||
|
|
2ea8e46723 | ||
|
|
977c6c6dde | ||
|
|
1e5db9dc6c | ||
|
|
252dc10e61 | ||
|
|
f9e2315ba1 | ||
|
|
0eca588280 | ||
|
|
33523bbfb9 | ||
|
|
110f23bdf1 | ||
|
|
50a5cb23f5 | ||
|
|
30e72db5ea | ||
|
|
c4c64c8fe8 | ||
|
|
df39350d7d | ||
|
|
5daacd3ed4 | ||
|
|
0f7f64fb2f | ||
|
|
b09a1fdcb7 | ||
|
|
e5d45dee3a | ||
|
|
f3270e19df | ||
|
|
1b9ad41c87 | ||
|
|
8c3365ef24 | ||
|
|
db5cbffb70 | ||
|
|
c6586f0eed | ||
|
|
f40bdc8ed9 | ||
|
|
683ac4e118 | ||
|
|
88c9abb78e | ||
|
|
1729a2f734 | ||
|
|
e5ad8bbb70 | ||
|
|
7f6acbfdbc | ||
|
|
2e070529e0 | ||
|
|
f4b31e5f0a | ||
|
|
f7f093a464 | ||
|
|
95cc340de5 | ||
|
|
51a003c816 | ||
|
|
977152139f | ||
|
|
c6798dbcd5 | ||
|
|
78833df95e | ||
|
|
099d941d2e | ||
|
|
ea58f378a9 | ||
|
|
4060884909 | ||
|
|
d9277c11d2 | ||
|
|
bcbc90820a | ||
|
|
e9caa95673 | ||
|
|
9cd6703b05 | ||
|
|
4233bd7771 | ||
|
|
0e2db441f2 | ||
|
|
571b101ea4 | ||
|
|
0b9dca61ab | ||
|
|
d8fa76d076 | ||
|
|
6116eef513 | ||
|
|
fabe987f2c | ||
|
|
af20880f37 | ||
|
|
44eeab0d4b | ||
|
|
b331900158 | ||
|
|
d74380404c | ||
|
|
3833a9cd6b | ||
|
|
74fdd943c9 | ||
|
|
ad5b772502 | ||
|
|
9bc941aa31 | ||
|
|
705df43047 | ||
|
|
b31e3bb710 | ||
|
|
4082d0a368 | ||
|
|
ce85286cdf | ||
|
|
415cf1a777 | ||
|
|
f249919ec8 | ||
|
|
054c9787d3 | ||
|
|
c1e88e2b5a | ||
|
|
f5efeb16c4 | ||
|
|
59afae7bca | ||
|
|
92c6339064 | ||
|
|
582ae233f2 | ||
|
|
3c3016a211 | ||
|
|
e24e1d9d3c | ||
|
|
85fb564be7 | ||
|
|
18f7db9eee | ||
|
|
ea0770fd11 | ||
|
|
9c28fab3f9 | ||
|
|
bac332a8c4 | ||
|
|
86d2542bd9 | ||
|
|
5f63ec884f | ||
|
|
0f87b33354 | ||
|
|
c7dc99a12f | ||
|
|
05c3c3f8f3 | ||
|
|
2437a43471 | ||
|
|
16cd812ba0 | ||
|
|
c036128720 | ||
|
|
645db15848 | ||
|
|
21e142ffc3 | ||
|
|
23a6b3db72 | ||
|
|
b87d109625 | ||
|
|
651bd295b9 | ||
|
|
01607e92b9 | ||
|
|
f40d54adbb | ||
|
|
60fb387495 | ||
|
|
a7df18723c | ||
|
|
4e39cc937a | ||
|
|
fe513c17ab | ||
|
|
f7fb88ed3d | ||
|
|
b3247c7cbe | ||
|
|
d490dc0a8b | ||
|
|
97e79c23f6 | ||
|
|
7803a29875 | ||
|
|
08093beed1 | ||
|
|
ae46ae89bc | ||
|
|
f7977dca39 | ||
|
|
0b344f3e08 | ||
|
|
6058154eca | ||
|
|
941016f04b | ||
|
|
7a15898087 | ||
|
|
6b4effa90f | ||
|
|
77d56e4a4c | ||
|
|
c4dd622e7b | ||
|
|
9f24535142 | ||
|
|
a45161fcb8 | ||
|
|
4379763a73 | ||
|
|
a25b376dfd | ||
|
|
fb429f6777 | ||
|
|
ae33227c3a | ||
|
|
2fedc61d1e | ||
|
|
6988a36dd1 | ||
|
|
60e1124970 | ||
|
|
a007684006 | ||
|
|
090a709522 | ||
|
|
9e06655214 | ||
|
|
3651825e79 | ||
|
|
dcf3160b58 | ||
|
|
fd014f42cd | ||
|
|
a760f2f7fc | ||
|
|
262c114b7d | ||
|
|
c90bdd83bb | ||
|
|
fdb2d37b12 | ||
|
|
7951837ecf | ||
|
|
d2e112a672 | ||
|
|
90e86077ba | ||
|
|
f69dfcdefe | ||
|
|
5cff7c4e72 | ||
|
|
0eaa833cdf | ||
|
|
db015d925e | ||
|
|
69fd47601e | ||
|
|
17521574f0 | ||
|
|
56650a76ae | ||
|
|
984a98e792 | ||
|
|
49eb0cf202 | ||
|
|
8e24a6b036 | ||
|
|
d07bf6d699 | ||
|
|
877d12c676 | ||
|
|
ca629f625a | ||
|
|
087dd9a4b6 | ||
|
|
d9a967c072 | ||
|
|
6d522c82c3 | ||
|
|
867af98083 | ||
|
|
1be058d831 | ||
|
|
71bc044ae5 | ||
|
|
d660c50ade | ||
|
|
3fd733d903 | ||
|
|
7703b2361d | ||
|
|
68fdb9ebc6 | ||
|
|
b6513343be | ||
|
|
cc95ff1c55 | ||
|
|
c954ee0fde | ||
|
|
1ada5e5d18 | ||
|
|
f5244c3d93 | ||
|
|
1f17776cd4 | ||
|
|
ebd4f36f94 | ||
|
|
dbb9b5f254 | ||
|
|
4301472cb2 | ||
|
|
15e7954321 | ||
|
|
32d1de08e9 | ||
|
|
ea88663dce | ||
|
|
97aecaf890 | ||
|
|
b16688e1b7 | ||
|
|
5ac856251b | ||
|
|
62a9aacd85 | ||
|
|
dff0b55a8c | ||
|
|
3b21c75b13 | ||
|
|
2c7cc4b8e5 | ||
|
|
bcf8c0e35c | ||
|
|
11d2936fca | ||
|
|
5e84429e24 | ||
|
|
48c88b2d7e | ||
|
|
e5185c0b77 | ||
|
|
15773d3aba | ||
|
|
b577837446 | ||
|
|
324e234b2a | ||
|
|
d40fefb0ea | ||
|
|
7e05568549 | ||
|
|
0249a52d1c | ||
|
|
5df1c5b09b | ||
|
|
953ec8fe31 | ||
|
|
6054afebdc | ||
|
|
04f356e119 | ||
|
|
a6ebefbb30 | ||
|
|
9a1edbe1fa | ||
|
|
c46d74be54 | ||
|
|
72e949586a | ||
|
|
68dacad741 | ||
|
|
7e6505ca73 | ||
|
|
362d83f504 | ||
|
|
e4879736b7 | ||
|
|
68fe6628c7 | ||
|
|
f39ef569be | ||
|
|
42c199376a | ||
|
|
cd6b774a8c | ||
|
|
62598f94f8 | ||
|
|
aa3b46141f | ||
|
|
dcaa2aaeab | ||
|
|
e36f5e47da | ||
|
|
578d7b41b4 | ||
|
|
c4f81ce279 | ||
|
|
02a7dbea85 | ||
|
|
0305d3fe36 | ||
|
|
84fd034197 | ||
|
|
61b48e2048 | ||
|
|
c91f85389c | ||
|
|
f3683355a9 | ||
|
|
a86da886f4 | ||
|
|
7bf8d42eb8 | ||
|
|
88533e29b9 | ||
|
|
8299dcc881 | ||
|
|
0d0c250fea | ||
|
|
eb8d86616b | ||
|
|
1edc90810f | ||
|
|
c011d84a35 | ||
|
|
b97f62ac12 | ||
|
|
200a38bdc0 | ||
|
|
e12c53a90e | ||
|
|
7892916f56 | ||
|
|
5d681beab3 | ||
|
|
a54a2affb3 | ||
|
|
120062bb85 | ||
|
|
a0754d2bd7 | ||
|
|
f4b13a84d4 | ||
|
|
1c98e27e4d | ||
|
|
6067e25df8 | ||
|
|
304c50a247 | ||
|
|
274ad8eac2 | ||
|
|
9946a3fc0e | ||
|
|
622d6db968 | ||
|
|
d756ce0656 | ||
|
|
316e776355 | ||
|
|
f861b7ad99 | ||
|
|
01382e98f3 | ||
|
|
3371442bbc | ||
|
|
a81a804364 | ||
|
|
b24cf5c946 | ||
|
|
3d60213644 | ||
|
|
a23822c9df | ||
|
|
9fa69758f1 | ||
|
|
c892d3f134 | ||
|
|
73271501dc | ||
|
|
a62ea520e7 | ||
|
|
a2a0b3c71e | ||
|
|
51bedbe003 | ||
|
|
7062a3e657 | ||
|
|
209d95d5da | ||
|
|
95ee74ab2b | ||
|
|
9f6b268dce | ||
|
|
96e8ebcc10 | ||
|
|
ca261c7037 | ||
|
|
a95fe13b31 | ||
|
|
c3fe76adf9 | ||
|
|
e9b9d51a6e | ||
|
|
aff66205e9 | ||
|
|
aa0c9d65ae | ||
|
|
423bd2122f | ||
|
|
7533910114 | ||
|
|
0d4d377c22 | ||
|
|
3a576b3ea5 | ||
|
|
27f37744a0 | ||
|
|
012ecad0bb | ||
|
|
49b5d5e961 | ||
|
|
01ca48fe96 | ||
|
|
5abe5045b2 | ||
|
|
8c79a577b9 | ||
|
|
5b18f1016f | ||
|
|
bda58312bb | ||
|
|
eff352a003 | ||
|
|
3109d4ff82 | ||
|
|
c44eda3af6 | ||
|
|
33e8c41c6f | ||
|
|
e0b515ba2b | ||
|
|
c6b6ad41fb | ||
|
|
99576238cd | ||
|
|
db712650ef | ||
|
|
d77e7b07eb | ||
|
|
47b35fad3f | ||
|
|
720105ca92 | ||
|
|
ce656068e3 | ||
|
|
e69ba76e7d | ||
|
|
d1bab213a3 | ||
|
|
016a9341ad | ||
|
|
81f407ad5f | ||
|
|
37ff2f6bdb | ||
|
|
13cf1a9551 | ||
|
|
799aaac449 | ||
|
|
fbe430e4cf | ||
|
|
511746620e | ||
|
|
fed5b648f8 | ||
|
|
1f601743f6 | ||
|
|
db3ccf5da7 | ||
|
|
038898d580 | ||
|
|
dafbc6bc51 | ||
|
|
3d8271e7a1 | ||
|
|
e2ade95219 | ||
|
|
604971a576 | ||
|
|
76173103af | ||
|
|
3fcbc60232 | ||
|
|
835bdb39d0 | ||
|
|
200f09c5d2 | ||
|
|
99a008f122 | ||
|
|
9b71e89ba0 | ||
|
|
9955369cfa | ||
|
|
6ef6ffab66 | ||
|
|
0dd46f0f20 | ||
|
|
cce0d85dd4 | ||
|
|
1ed3eae038 | ||
|
|
2854c24197 | ||
|
|
4b90ddda2a | ||
|
|
552c39e583 | ||
|
|
5176e459b5 | ||
|
|
39515d0600 | ||
|
|
5314949394 | ||
|
|
59e5be749c | ||
|
|
da252a0070 | ||
|
|
b6199e430c | ||
|
|
e260a68c26 | ||
|
|
570cb2deaf | ||
|
|
7a36f03fb5 | ||
|
|
6c3058ba97 | ||
|
|
48a1034c12 | ||
|
|
aaddb50ab9 | ||
|
|
a947586cfe | ||
|
|
f3cfa14a59 | ||
|
|
a2c0a77c53 | ||
|
|
c078f9d5cb | ||
|
|
08eab8a157 | ||
|
|
9c36f234bc | ||
|
|
36b81d0e2a | ||
|
|
4432c14377 | ||
|
|
99e6dd5ca3 | ||
|
|
6327a25aec | ||
|
|
40989de7f5 | ||
|
|
58af44e795 | ||
|
|
4910cc05f8 | ||
|
|
164ee24d16 | ||
|
|
0ffda4ab7c | ||
|
|
4694a6271d | ||
|
|
2f50bc747a | ||
|
|
a13f621a81 | ||
|
|
0cc520cc67 | ||
|
|
c9451c3f2d | ||
|
|
8da53ffda2 | ||
|
|
4319761687 | ||
|
|
a1f3227cd9 | ||
|
|
b07f87c920 | ||
|
|
9c8f23eb64 | ||
|
|
36c6c623de | ||
|
|
e334419e24 | ||
|
|
01240c4f2a | ||
|
|
431a9ce827 | ||
|
|
20ab41c3b4 | ||
|
|
3f4ea27be2 | ||
|
|
5bb5b2f8c8 | ||
|
|
e9bb9e27bb | ||
|
|
ee1dd1fae7 | ||
|
|
738530a960 | ||
|
|
16023b0688 | ||
|
|
bce7b5984f | ||
|
|
4e5d7d391a | ||
|
|
0554a85e01 | ||
|
|
2d232b3c4b | ||
|
|
b2a5462372 | ||
|
|
9d61f51270 | ||
|
|
a0eb7d61cc | ||
|
|
9861b319f4 | ||
|
|
b04e0f10b2 | ||
|
|
e6d089b34b | ||
|
|
9df014e986 | ||
|
|
39b8d2e70d | ||
|
|
59710d2e1a | ||
|
|
712dc9bb34 | ||
|
|
5c338d53ae | ||
|
|
ec3eb387e5 | ||
|
|
722cf58486 | ||
|
|
17e5347d55 | ||
|
|
2e828dfde3 | ||
|
|
1b035bcde3 | ||
|
|
30981130c9 | ||
|
|
6db8696a36 | ||
|
|
9c83dcafa7 | ||
|
|
8d26842aab | ||
|
|
76e654304b | ||
|
|
32ba679719 | ||
|
|
cac0fefcdb | ||
|
|
498c76dd96 | ||
|
|
7526182304 | ||
|
|
d46bbd9cbf | ||
|
|
e98b537499 | ||
|
|
fc9d8509e5 | ||
|
|
7c6bbb61b5 | ||
|
|
8501ddc87f | ||
|
|
7d9b94525e | ||
|
|
eb02f0eadf | ||
|
|
69a8ba5aec | ||
|
|
fe49b8e618 | ||
|
|
26bf4dde5f | ||
|
|
e9b73050ba | ||
|
|
bacd0ab43f | ||
|
|
e438051371 | ||
|
|
314155593d | ||
|
|
787f819ce0 | ||
|
|
3632a62ea3 | ||
|
|
c7294df007 | ||
|
|
e3ad7fe3c0 | ||
|
|
6213683ddf | ||
|
|
a4ddfc9970 | ||
|
|
7ff271f9b9 | ||
|
|
d75cdd63a9 | ||
|
|
0a7575d1e4 | ||
|
|
ec3022d8ad | ||
|
|
d42103b91b | ||
|
|
00f7d08b04 | ||
|
|
408cc383cb | ||
|
|
5c926d0ac6 | ||
|
|
5cb88782fc | ||
|
|
5eef4e9ece | ||
|
|
04a2124141 | ||
|
|
1b6229b2a1 | ||
|
|
bca6db50a7 | ||
|
|
f3aae26996 | ||
|
|
f3c17a8e0f | ||
|
|
d6b24f8753 | ||
|
|
253db0a303 | ||
|
|
8499e52461 | ||
|
|
d0153179a9 | ||
|
|
264d03727e | ||
|
|
544405d9b9 | ||
|
|
24488a3b67 | ||
|
|
ae78185b29 | ||
|
|
7f682b24ef | ||
|
|
d42a52d8cf | ||
|
|
b85df15890 | ||
|
|
393499f34f | ||
|
|
c656d0f9b5 | ||
|
|
32017a8859 | ||
|
|
d87c5b1140 | ||
|
|
f59f783d3f | ||
|
|
ec298eac61 | ||
|
|
81a25bb4ee | ||
|
|
e99f044e45 | ||
|
|
5ae25a5fd9 | ||
|
|
e9d1cb4907 | ||
|
|
99f8ca2dca | ||
|
|
ddea5d38b5 | ||
|
|
31d2b83550 | ||
|
|
5535e56ed2 | ||
|
|
d740b95dbc | ||
|
|
ae92e9a945 | ||
|
|
29730c3896 | ||
|
|
a5ae8270f0 | ||
|
|
54f5fa6432 | ||
|
|
0260644063 | ||
|
|
267fc03a82 | ||
|
|
bf1537584c | ||
|
|
9ee7825022 | ||
|
|
2be0c42dd1 | ||
|
|
3423c053a2 | ||
|
|
26923cca00 | ||
|
|
36e027659c | ||
|
|
f447dccdb4 | ||
|
|
69eae32851 | ||
|
|
aa2fcfb8cb | ||
|
|
fae5ef2a41 | ||
|
|
7251ca7d2d | ||
|
|
7cdbd919bf | ||
|
|
d450f95602 | ||
|
|
5a65201971 | ||
|
|
d303b8db3e | ||
|
|
06baa33827 | ||
|
|
42743e637e | ||
|
|
9969fd7dec | ||
|
|
fc6d5c2a1d | ||
|
|
f8abb1ca24 | ||
|
|
a5af38ae3d | ||
|
|
aab70951dc | ||
|
|
334cacf93c | ||
|
|
53024012fc | ||
|
|
86a72f77c1 | ||
|
|
bc11a5ad0a | ||
|
|
8f2d217fd4 | ||
|
|
183727cd50 | ||
|
|
676e87591a | ||
|
|
8c05fc4da0 | ||
|
|
2bab06561e | ||
|
|
dfa7e2f5bb | ||
|
|
78bece5616 | ||
|
|
eeea15e373 | ||
|
|
80cd513ab7 | ||
|
|
942ef1615e | ||
|
|
a354698022 | ||
|
|
0cdea488c9 | ||
|
|
4f4291ac47 | ||
|
|
bf0cf03091 | ||
|
|
f7da09f20f | ||
|
|
be1529331c | ||
|
|
301d658a29 | ||
|
|
1cc54e5b2c | ||
|
|
65a7fd21e7 | ||
|
|
856537c0cd | ||
|
|
b2a88e0063 | ||
|
|
85a7068785 | ||
|
|
cbb733d99a | ||
|
|
ce88c94a19 | ||
|
|
16516915d8 | ||
|
|
6addb8da23 | ||
|
|
bc7f664fd8 | ||
|
|
aac17aa33c | ||
|
|
825500e207 | ||
|
|
4d42016c72 | ||
|
|
9d665df602 | ||
|
|
9087f69fb0 | ||
|
|
2a06f6a214 | ||
|
|
78a8428bd0 | ||
|
|
0d235768fa | ||
|
|
af092bbdec | ||
|
|
4961630d62 | ||
|
|
81920b9ab9 | ||
|
|
9e031d3b5b | ||
|
|
7ae3ed6d2a | ||
|
|
05d79f2b51 | ||
|
|
274e591354 | ||
|
|
95fd152b3d | ||
|
|
ffc91ed6d8 | ||
|
|
de0b759875 | ||
|
|
f041dcf944 | ||
|
|
946b9bd9d1 | ||
|
|
db77a69838 | ||
|
|
d10f4d26e2 | ||
|
|
6b62d686ba | ||
|
|
065826e64d | ||
|
|
a3b096343f | ||
|
|
b33be91b06 | ||
|
|
a94a1816c5 | ||
|
|
9a9e7fea07 | ||
|
|
9a03042077 | ||
|
|
704d3b2d6b | ||
|
|
e5c2be238d | ||
|
|
9feea07527 | ||
|
|
b0967d03b8 | ||
|
|
d33fd71f93 | ||
|
|
226b5b2682 | ||
|
|
f8879b0223 | ||
|
|
ada09d96c4 |
98
.github/workflows/deploy.yml
vendored
98
.github/workflows/deploy.yml
vendored
@@ -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@v4
|
||||
build_site:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- 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 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: Build gpx
|
||||
run: npm run build --prefix gpx
|
||||
- name: Install dependencies for gpx
|
||||
run: npm install --prefix gpx
|
||||
|
||||
- name: Install dependencies for website
|
||||
run: npm install --prefix website
|
||||
- name: Build gpx
|
||||
run: npm run build --prefix gpx
|
||||
|
||||
- name: Create env file
|
||||
run: |
|
||||
touch website/.env
|
||||
echo PUBLIC_MAPBOX_TOKEN=${{ secrets.PUBLIC_MAPBOX_TOKEN }} >> website/.env
|
||||
cat website/.env
|
||||
- name: Install dependencies for website
|
||||
run: npm install --prefix website
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
BASE_PATH: ''
|
||||
run: |
|
||||
npm run build --prefix website
|
||||
- name: Create env file
|
||||
run: |
|
||||
touch website/.env
|
||||
echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env
|
||||
cat website/.env
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: 'website/build/'
|
||||
- name: Build website
|
||||
env:
|
||||
BASE_PATH: ''
|
||||
run: |
|
||||
npm run build --prefix website
|
||||
|
||||
deploy:
|
||||
needs: build_site
|
||||
runs-on: ubuntu-latest
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: 'website/build/'
|
||||
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
deploy:
|
||||
needs: build_site
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
steps:
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
src/lib/components/ui
|
||||
*.mdx
|
||||
website/src/lib/components/ui
|
||||
website/src/lib/docs/**/*.mdx
|
||||
**/*.webmanifest
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 gpx.studio
|
||||
Copyright (c) 2026 gpx.studio
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
46
README.md
46
README.md
@@ -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://account.mapbox.com/auth/signup" target="_blank">Mapbox access token</a> and store it in a `.env` file in the `website` directory.
|
||||
To be able to load the map, you will need to create your own <a href="https://cloud.maptiler.com/auth/widget?next=https://cloud.maptiler.com/maps/" target="_blank">MapTiler key</a> and store it in a `.env` file in the `website` directory.
|
||||
|
||||
```bash
|
||||
cd website
|
||||
echo PUBLIC_MAPBOX_TOKEN={YOUR_MAPBOX_TOKEN} >> .env
|
||||
echo PUBLIC_MAPTILER_KEY={YOUR_MAPTILER_KEY} >> .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:
|
||||
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
|
||||
- [brouter](https://github.com/abrensch/brouter) — routing engine
|
||||
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
|
||||
- Search:
|
||||
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
||||
- Development:
|
||||
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience
|
||||
- [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation
|
||||
- Design:
|
||||
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
|
||||
- [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons
|
||||
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
|
||||
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
|
||||
- Logic:
|
||||
- [immer](https://github.com/immerjs/immer) — complex state management
|
||||
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
|
||||
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
|
||||
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
|
||||
- Mapping:
|
||||
- [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive maps
|
||||
- [brouter](https://github.com/abrensch/brouter) — 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
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"postinstall": "npm run build",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
"lint": "prettier --check . --config ../.prettierrc && eslint .",
|
||||
"format": "prettier --write . --config ../.prettierrc"
|
||||
}
|
||||
}
|
||||
|
||||
677
gpx/src/gpx.ts
677
gpx/src/gpx.ts
@@ -1,4 +1,5 @@
|
||||
import { ramerDouglasPeucker } from './simplify';
|
||||
import { GPXStatistics, GPXStatisticsGroup, TrackPointLocalStatistics } from './statistics';
|
||||
import {
|
||||
Coordinates,
|
||||
GPXFileAttributes,
|
||||
@@ -17,6 +18,9 @@ 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;
|
||||
}
|
||||
@@ -33,7 +37,6 @@ 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[];
|
||||
|
||||
@@ -73,14 +76,6 @@ 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());
|
||||
}
|
||||
@@ -145,7 +140,9 @@ export class GPXFile extends GPXTreeNode<Track> {
|
||||
},
|
||||
},
|
||||
};
|
||||
this.wpt = gpx.wpt ? gpx.wpt.map((waypoint) => new Waypoint(waypoint)) : [];
|
||||
this.wpt = gpx.wpt
|
||||
? gpx.wpt.map((waypoint, index) => new Waypoint(waypoint, index))
|
||||
: [];
|
||||
this.trk = gpx.trk ? gpx.trk.map((track) => new Track(track)) : [];
|
||||
if (gpx.rte && gpx.rte.length > 0) {
|
||||
this.trk = this.trk.concat(gpx.rte.map((route) => convertRouteToTrack(route)));
|
||||
@@ -183,9 +180,6 @@ export class GPXFile extends GPXTreeNode<Track> {
|
||||
segment._data['segmentIndex'] = segmentIndex;
|
||||
});
|
||||
});
|
||||
this.wpt.forEach((waypoint, waypointIndex) => {
|
||||
waypoint._data['index'] = waypointIndex;
|
||||
});
|
||||
}
|
||||
|
||||
get children(): Array<Track> {
|
||||
@@ -206,8 +200,16 @@ export class GPXFile extends GPXTreeNode<Track> {
|
||||
});
|
||||
}
|
||||
|
||||
getStatistics(): GPXStatisticsGroup {
|
||||
let statistics = new GPXStatisticsGroup();
|
||||
this.forEachSegment((segment) => {
|
||||
statistics.add(segment.getStatistics());
|
||||
});
|
||||
return statistics;
|
||||
}
|
||||
|
||||
getStyle(defaultColor?: string): MergedLineStyles {
|
||||
return this.trk
|
||||
const style = this.trk
|
||||
.map((track) => track.getStyle())
|
||||
.reduce(
|
||||
(acc, style) => {
|
||||
@@ -217,8 +219,6 @@ 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,6 +242,10 @@ export class GPXFile extends GPXTreeNode<Track> {
|
||||
width: [],
|
||||
}
|
||||
);
|
||||
if (style.color.length === 0 && defaultColor) {
|
||||
style.color.push(defaultColor);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
clone(): GPXFile {
|
||||
@@ -804,7 +808,7 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
constructor(segment?: (TrackSegmentType & { _data?: any }) | TrackSegment) {
|
||||
super();
|
||||
if (segment) {
|
||||
this.trkpt = segment.trkpt.map((point) => new TrackPoint(point));
|
||||
this.trkpt = segment.trkpt.map((point, index) => new TrackPoint(point, index));
|
||||
if (segment.hasOwnProperty('_data')) {
|
||||
this._data = segment._data;
|
||||
}
|
||||
@@ -816,15 +820,12 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
_computeStatistics(): GPXStatistics {
|
||||
let statistics = new GPXStatistics();
|
||||
|
||||
statistics.local.points = this.trkpt.map((point) => point);
|
||||
|
||||
statistics.local.elevation.smoothed = this._computeSmoothedElevation();
|
||||
statistics.local.slope.at = this._computeSlope();
|
||||
statistics.global.length = this.trkpt.length;
|
||||
statistics.local.points = this.trkpt.slice(0);
|
||||
statistics.local.data = this.trkpt.map(() => new TrackPointLocalStatistics());
|
||||
|
||||
const points = this.trkpt;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
points[i]._data['index'] = i;
|
||||
|
||||
// distance
|
||||
let dist = 0;
|
||||
if (i > 0) {
|
||||
@@ -833,34 +834,18 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
statistics.global.distance.total += dist;
|
||||
}
|
||||
|
||||
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);
|
||||
statistics.local.data[i].distance.total = statistics.global.distance.total;
|
||||
|
||||
// time
|
||||
if (points[i].time === undefined) {
|
||||
statistics.local.time.total.push(0);
|
||||
statistics.local.data[i].time.total = 0;
|
||||
} else {
|
||||
if (statistics.global.time.start === undefined) {
|
||||
statistics.global.time.start = points[i].time;
|
||||
}
|
||||
statistics.global.time.end = points[i].time;
|
||||
statistics.local.time.total.push(
|
||||
(points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000
|
||||
);
|
||||
statistics.local.data[i].time.total =
|
||||
(points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000;
|
||||
}
|
||||
|
||||
// speed
|
||||
@@ -875,8 +860,8 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
}
|
||||
}
|
||||
|
||||
statistics.local.distance.moving.push(statistics.global.distance.moving);
|
||||
statistics.local.time.moving.push(statistics.global.time.moving);
|
||||
statistics.local.data[i].distance.moving = statistics.global.distance.moving;
|
||||
statistics.local.data[i].time.moving = statistics.global.time.moving;
|
||||
|
||||
// bounds
|
||||
statistics.global.bounds.southWest.lat = Math.min(
|
||||
@@ -960,8 +945,7 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
}
|
||||
}
|
||||
|
||||
[statistics.local.slope.segment, statistics.local.slope.length] =
|
||||
this._computeSlopeSegments(statistics);
|
||||
this._elevationComputation(statistics);
|
||||
|
||||
statistics.global.time.total =
|
||||
statistics.global.time.start && statistics.global.time.end
|
||||
@@ -977,73 +961,115 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
? statistics.global.distance.moving / (statistics.global.time.moving / 3600)
|
||||
: 0;
|
||||
|
||||
statistics.local.speed = distanceWindowSmoothingWithDistanceAccumulator(
|
||||
timeWindowSmoothing(
|
||||
points,
|
||||
200,
|
||||
(accumulated, start, end) =>
|
||||
10000,
|
||||
(start, end) =>
|
||||
points[start].time && points[end].time
|
||||
? (3600 * accumulated) /
|
||||
(points[end].time.getTime() - points[start].time.getTime())
|
||||
: undefined
|
||||
? (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;
|
||||
}
|
||||
);
|
||||
|
||||
return statistics;
|
||||
}
|
||||
|
||||
_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[]] {
|
||||
_elevationComputation(statistics: GPXStatistics) {
|
||||
let simplified = ramerDouglasPeucker(
|
||||
this.trkpt,
|
||||
20,
|
||||
getElevationDistanceFunction(statistics)
|
||||
);
|
||||
|
||||
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 cumulEle = 0;
|
||||
let currentStart = start;
|
||||
let currentEnd = start;
|
||||
let prevSmoothedEle = 0;
|
||||
distanceWindowSmoothing(
|
||||
start,
|
||||
end + 1,
|
||||
statistics,
|
||||
0.1,
|
||||
(s, e) => {
|
||||
for (let i = currentStart; i < s; i++) {
|
||||
cumulEle -= this.trkpt[i].ele ?? 0;
|
||||
}
|
||||
for (let i = currentEnd; i <= e; i++) {
|
||||
cumulEle += this.trkpt[i].ele ?? 0;
|
||||
}
|
||||
currentStart = s;
|
||||
currentEnd = e + 1;
|
||||
return cumulEle / (e - s + 1);
|
||||
},
|
||||
(smoothedEle, j) => {
|
||||
if (j === start) {
|
||||
smoothedEle = this.trkpt[start].ele ?? 0;
|
||||
prevSmoothedEle = smoothedEle;
|
||||
} else if (j === end) {
|
||||
smoothedEle = this.trkpt[end].ele ?? 0;
|
||||
}
|
||||
const ele = smoothedEle - prevSmoothedEle;
|
||||
if (ele > 0) {
|
||||
statistics.global.elevation.gain += ele;
|
||||
} else if (ele < 0) {
|
||||
statistics.global.elevation.loss -= ele;
|
||||
}
|
||||
prevSmoothedEle = smoothedEle;
|
||||
if (j < end) {
|
||||
statistics.local.data[j].elevation.gain = statistics.global.elevation.gain;
|
||||
statistics.local.data[j].elevation.loss = statistics.global.elevation.loss;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (statistics.global.length > 0) {
|
||||
statistics.local.data[statistics.global.length - 1].elevation.gain =
|
||||
statistics.global.elevation.gain;
|
||||
statistics.local.data[statistics.global.length - 1].elevation.loss =
|
||||
statistics.global.elevation.loss;
|
||||
}
|
||||
|
||||
for (let i = 0; i < simplified.length - 1; i++) {
|
||||
let start = simplified[i].point._data.index;
|
||||
let end = simplified[i + 1].point._data.index;
|
||||
let dist =
|
||||
statistics.local.distance.total[end] - statistics.local.distance.total[start];
|
||||
statistics.local.data[end].distance.total -
|
||||
statistics.local.data[start].distance.total;
|
||||
let ele = (simplified[i + 1].point.ele ?? 0) - (simplified[i].point.ele ?? 0);
|
||||
|
||||
for (let j = start; j < end + (i + 1 === simplified.length - 1 ? 1 : 0); j++) {
|
||||
slope.push((0.1 * ele) / dist);
|
||||
length.push(dist);
|
||||
statistics.local.data[j].slope.segment = (0.1 * ele) / dist;
|
||||
statistics.local.data[j].slope.length = dist;
|
||||
}
|
||||
}
|
||||
|
||||
return [slope, length];
|
||||
distanceWindowSmoothing(
|
||||
0,
|
||||
this.trkpt.length,
|
||||
statistics,
|
||||
0.05,
|
||||
(start, end) => {
|
||||
const ele = this.trkpt[end].ele - this.trkpt[start].ele || 0;
|
||||
const dist =
|
||||
statistics.local.data[end].distance.total -
|
||||
statistics.local.data[start].distance.total;
|
||||
return dist > 0 ? (0.1 * ele) / dist : 0;
|
||||
},
|
||||
(value, index) => {
|
||||
statistics.local.data[index].slope.at = value;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getNumberOfTrackPoints(): number {
|
||||
@@ -1290,8 +1316,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 slope = og._computeSlope();
|
||||
let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, slope);
|
||||
let statistics = og._computeStatistics();
|
||||
let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, statistics);
|
||||
this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
|
||||
}
|
||||
|
||||
@@ -1300,6 +1326,7 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
}
|
||||
}
|
||||
|
||||
const emptyExtensions: Record<string, string> = {};
|
||||
export class TrackPoint {
|
||||
[immerable] = true;
|
||||
|
||||
@@ -1310,7 +1337,7 @@ export class TrackPoint {
|
||||
|
||||
_data: { [key: string]: any } = {};
|
||||
|
||||
constructor(point: (TrackPointType & { _data?: any }) | TrackPoint) {
|
||||
constructor(point: (TrackPointType & { _data?: any }) | TrackPoint, index?: number) {
|
||||
this.attributes = point.attributes;
|
||||
this.ele = point.ele;
|
||||
this.time = point.time;
|
||||
@@ -1318,6 +1345,9 @@ export class TrackPoint {
|
||||
if (point.hasOwnProperty('_data')) {
|
||||
this._data = point._data;
|
||||
}
|
||||
if (index !== undefined) {
|
||||
this._data.index = index;
|
||||
}
|
||||
}
|
||||
|
||||
getCoordinates(): Coordinates {
|
||||
@@ -1391,7 +1421,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 {
|
||||
@@ -1461,11 +1491,18 @@ export class TrackPoint {
|
||||
|
||||
clone(): TrackPoint {
|
||||
return new TrackPoint({
|
||||
attributes: cloneJSON(this.attributes),
|
||||
attributes: {
|
||||
lat: this.attributes.lat,
|
||||
lon: this.attributes.lon,
|
||||
},
|
||||
ele: this.ele,
|
||||
time: this.time ? new Date(this.time.getTime()) : undefined,
|
||||
extensions: cloneJSON(this.extensions),
|
||||
_data: cloneJSON(this._data),
|
||||
extensions: this.extensions ? cloneJSON(this.extensions) : undefined,
|
||||
_data: {
|
||||
index: this._data?.index,
|
||||
anchor: this._data?.anchor,
|
||||
zoom: this._data?.zoom,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1484,19 +1521,28 @@ export class Waypoint {
|
||||
type?: string;
|
||||
_data: { [key: string]: any } = {};
|
||||
|
||||
constructor(waypoint: (WaypointType & { _data?: any }) | Waypoint) {
|
||||
constructor(waypoint: (WaypointType & { _data?: any }) | Waypoint, index?: number) {
|
||||
this.attributes = waypoint.attributes;
|
||||
this.ele = waypoint.ele;
|
||||
this.time = waypoint.time;
|
||||
this.name = waypoint.name;
|
||||
this.cmt = waypoint.cmt;
|
||||
this.desc = waypoint.desc;
|
||||
this.link = waypoint.link;
|
||||
this.sym = waypoint.sym;
|
||||
this.type = waypoint.type;
|
||||
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;
|
||||
if (waypoint.hasOwnProperty('_data')) {
|
||||
this._data = waypoint._data;
|
||||
}
|
||||
if (index !== undefined) {
|
||||
this._data.index = index;
|
||||
}
|
||||
}
|
||||
|
||||
getCoordinates(): Coordinates {
|
||||
@@ -1544,7 +1590,10 @@ export class Waypoint {
|
||||
|
||||
clone(): Waypoint {
|
||||
return new Waypoint({
|
||||
attributes: cloneJSON(this.attributes),
|
||||
attributes: {
|
||||
lat: this.attributes.lat,
|
||||
lon: this.attributes.lon,
|
||||
},
|
||||
ele: this.ele,
|
||||
time: this.time ? new Date(this.time.getTime()) : undefined,
|
||||
name: this.name,
|
||||
@@ -1593,310 +1642,6 @@ export class Waypoint {
|
||||
}
|
||||
}
|
||||
|
||||
export class GPXStatistics {
|
||||
global: {
|
||||
distance: {
|
||||
moving: number;
|
||||
total: number;
|
||||
};
|
||||
time: {
|
||||
start: Date | undefined;
|
||||
end: Date | undefined;
|
||||
moving: number;
|
||||
total: number;
|
||||
};
|
||||
speed: {
|
||||
moving: number;
|
||||
total: number;
|
||||
};
|
||||
elevation: {
|
||||
gain: number;
|
||||
loss: number;
|
||||
};
|
||||
bounds: {
|
||||
southWest: Coordinates;
|
||||
northEast: Coordinates;
|
||||
};
|
||||
atemp: {
|
||||
avg: number;
|
||||
count: number;
|
||||
};
|
||||
hr: {
|
||||
avg: number;
|
||||
count: number;
|
||||
};
|
||||
cad: {
|
||||
avg: number;
|
||||
count: number;
|
||||
};
|
||||
power: {
|
||||
avg: number;
|
||||
count: number;
|
||||
};
|
||||
extensions: Record<string, Record<string, number>>;
|
||||
};
|
||||
local: {
|
||||
points: TrackPoint[];
|
||||
distance: {
|
||||
moving: number[];
|
||||
total: number[];
|
||||
};
|
||||
time: {
|
||||
moving: number[];
|
||||
total: number[];
|
||||
};
|
||||
speed: number[];
|
||||
elevation: {
|
||||
smoothed: number[];
|
||||
gain: number[];
|
||||
loss: number[];
|
||||
};
|
||||
slope: {
|
||||
at: number[];
|
||||
segment: number[];
|
||||
length: number[];
|
||||
};
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.global = {
|
||||
distance: {
|
||||
moving: 0,
|
||||
total: 0,
|
||||
},
|
||||
time: {
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
moving: 0,
|
||||
total: 0,
|
||||
},
|
||||
speed: {
|
||||
moving: 0,
|
||||
total: 0,
|
||||
},
|
||||
elevation: {
|
||||
gain: 0,
|
||||
loss: 0,
|
||||
},
|
||||
bounds: {
|
||||
southWest: {
|
||||
lat: 90,
|
||||
lon: 180,
|
||||
},
|
||||
northEast: {
|
||||
lat: -90,
|
||||
lon: -180,
|
||||
},
|
||||
},
|
||||
atemp: {
|
||||
avg: 0,
|
||||
count: 0,
|
||||
},
|
||||
hr: {
|
||||
avg: 0,
|
||||
count: 0,
|
||||
},
|
||||
cad: {
|
||||
avg: 0,
|
||||
count: 0,
|
||||
},
|
||||
power: {
|
||||
avg: 0,
|
||||
count: 0,
|
||||
},
|
||||
extensions: {},
|
||||
};
|
||||
this.local = {
|
||||
points: [],
|
||||
distance: {
|
||||
moving: [],
|
||||
total: [],
|
||||
},
|
||||
time: {
|
||||
moving: [],
|
||||
total: [],
|
||||
},
|
||||
speed: [],
|
||||
elevation: {
|
||||
smoothed: [],
|
||||
gain: [],
|
||||
loss: [],
|
||||
},
|
||||
slope: {
|
||||
at: [],
|
||||
segment: [],
|
||||
length: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
mergeWith(other: GPXStatistics): void {
|
||||
this.local.points = this.local.points.concat(other.local.points);
|
||||
|
||||
this.local.distance.total = this.local.distance.total.concat(
|
||||
other.local.distance.total.map((distance) => distance + this.global.distance.total)
|
||||
);
|
||||
this.local.distance.moving = this.local.distance.moving.concat(
|
||||
other.local.distance.moving.map((distance) => distance + this.global.distance.moving)
|
||||
);
|
||||
this.local.time.total = this.local.time.total.concat(
|
||||
other.local.time.total.map((time) => time + this.global.time.total)
|
||||
);
|
||||
this.local.time.moving = this.local.time.moving.concat(
|
||||
other.local.time.moving.map((time) => time + this.global.time.moving)
|
||||
);
|
||||
this.local.elevation.gain = this.local.elevation.gain.concat(
|
||||
other.local.elevation.gain.map((gain) => gain + this.global.elevation.gain)
|
||||
);
|
||||
this.local.elevation.loss = this.local.elevation.loss.concat(
|
||||
other.local.elevation.loss.map((loss) => loss + this.global.elevation.loss)
|
||||
);
|
||||
|
||||
this.local.speed = this.local.speed.concat(other.local.speed);
|
||||
this.local.elevation.smoothed = this.local.elevation.smoothed.concat(
|
||||
other.local.elevation.smoothed
|
||||
);
|
||||
this.local.slope.at = this.local.slope.at.concat(other.local.slope.at);
|
||||
this.local.slope.segment = this.local.slope.segment.concat(other.local.slope.segment);
|
||||
this.local.slope.length = this.local.slope.length.concat(other.local.slope.length);
|
||||
|
||||
this.global.distance.total += other.global.distance.total;
|
||||
this.global.distance.moving += other.global.distance.moving;
|
||||
|
||||
this.global.time.start =
|
||||
this.global.time.start !== undefined && other.global.time.start !== undefined
|
||||
? new Date(
|
||||
Math.min(this.global.time.start.getTime(), other.global.time.start.getTime())
|
||||
)
|
||||
: (this.global.time.start ?? other.global.time.start);
|
||||
this.global.time.end =
|
||||
this.global.time.end !== undefined && other.global.time.end !== undefined
|
||||
? new Date(
|
||||
Math.max(this.global.time.end.getTime(), other.global.time.end.getTime())
|
||||
)
|
||||
: (this.global.time.end ?? other.global.time.end);
|
||||
|
||||
this.global.time.total += other.global.time.total;
|
||||
this.global.time.moving += other.global.time.moving;
|
||||
|
||||
this.global.speed.moving =
|
||||
this.global.time.moving > 0
|
||||
? this.global.distance.moving / (this.global.time.moving / 3600)
|
||||
: 0;
|
||||
this.global.speed.total =
|
||||
this.global.time.total > 0
|
||||
? this.global.distance.total / (this.global.time.total / 3600)
|
||||
: 0;
|
||||
|
||||
this.global.elevation.gain += other.global.elevation.gain;
|
||||
this.global.elevation.loss += other.global.elevation.loss;
|
||||
|
||||
this.global.bounds.southWest.lat = Math.min(
|
||||
this.global.bounds.southWest.lat,
|
||||
other.global.bounds.southWest.lat
|
||||
);
|
||||
this.global.bounds.southWest.lon = Math.min(
|
||||
this.global.bounds.southWest.lon,
|
||||
other.global.bounds.southWest.lon
|
||||
);
|
||||
this.global.bounds.northEast.lat = Math.max(
|
||||
this.global.bounds.northEast.lat,
|
||||
other.global.bounds.northEast.lat
|
||||
);
|
||||
this.global.bounds.northEast.lon = Math.max(
|
||||
this.global.bounds.northEast.lon,
|
||||
other.global.bounds.northEast.lon
|
||||
);
|
||||
|
||||
this.global.atemp.avg =
|
||||
(this.global.atemp.count * this.global.atemp.avg +
|
||||
other.global.atemp.count * other.global.atemp.avg) /
|
||||
Math.max(1, this.global.atemp.count + other.global.atemp.count);
|
||||
this.global.atemp.count += other.global.atemp.count;
|
||||
this.global.hr.avg =
|
||||
(this.global.hr.count * this.global.hr.avg +
|
||||
other.global.hr.count * other.global.hr.avg) /
|
||||
Math.max(1, this.global.hr.count + other.global.hr.count);
|
||||
this.global.hr.count += other.global.hr.count;
|
||||
this.global.cad.avg =
|
||||
(this.global.cad.count * this.global.cad.avg +
|
||||
other.global.cad.count * other.global.cad.avg) /
|
||||
Math.max(1, this.global.cad.count + other.global.cad.count);
|
||||
this.global.cad.count += other.global.cad.count;
|
||||
this.global.power.avg =
|
||||
(this.global.power.count * this.global.power.avg +
|
||||
other.global.power.count * other.global.power.avg) /
|
||||
Math.max(1, this.global.power.count + other.global.power.count);
|
||||
this.global.power.count += other.global.power.count;
|
||||
Object.keys(other.global.extensions).forEach((extension) => {
|
||||
if (this.global.extensions[extension] === undefined) {
|
||||
this.global.extensions[extension] = {};
|
||||
}
|
||||
Object.keys(other.global.extensions[extension]).forEach((value) => {
|
||||
if (this.global.extensions[extension][value] === undefined) {
|
||||
this.global.extensions[extension][value] = 0;
|
||||
}
|
||||
this.global.extensions[extension][value] +=
|
||||
other.global.extensions[extension][value];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
slice(start: number, end: number): GPXStatistics {
|
||||
if (start < 0) {
|
||||
start = 0;
|
||||
} else if (start >= this.local.points.length) {
|
||||
return new GPXStatistics();
|
||||
}
|
||||
if (end < start) {
|
||||
return new GPXStatistics();
|
||||
} else if (end >= this.local.points.length) {
|
||||
end = this.local.points.length - 1;
|
||||
}
|
||||
|
||||
let statistics = new GPXStatistics();
|
||||
|
||||
statistics.local.points = this.local.points.slice(start, end + 1);
|
||||
|
||||
statistics.global.distance.total =
|
||||
this.local.distance.total[end] - this.local.distance.total[start];
|
||||
statistics.global.distance.moving =
|
||||
this.local.distance.moving[end] - this.local.distance.moving[start];
|
||||
|
||||
statistics.global.time.start = this.local.points[start].time;
|
||||
statistics.global.time.end = this.local.points[end].time;
|
||||
|
||||
statistics.global.time.total = this.local.time.total[end] - this.local.time.total[start];
|
||||
statistics.global.time.moving = this.local.time.moving[end] - this.local.time.moving[start];
|
||||
|
||||
statistics.global.speed.moving =
|
||||
statistics.global.time.moving > 0
|
||||
? statistics.global.distance.moving / (statistics.global.time.moving / 3600)
|
||||
: 0;
|
||||
statistics.global.speed.total =
|
||||
statistics.global.time.total > 0
|
||||
? statistics.global.distance.total / (statistics.global.time.total / 3600)
|
||||
: 0;
|
||||
|
||||
statistics.global.elevation.gain =
|
||||
this.local.elevation.gain[end] - this.local.elevation.gain[start];
|
||||
statistics.global.elevation.loss =
|
||||
this.local.elevation.loss[end] - this.local.elevation.loss[start];
|
||||
|
||||
statistics.global.bounds.southWest.lat = this.global.bounds.southWest.lat;
|
||||
statistics.global.bounds.southWest.lon = this.global.bounds.southWest.lon;
|
||||
statistics.global.bounds.northEast.lat = this.global.bounds.northEast.lat;
|
||||
statistics.global.bounds.northEast.lon = this.global.bounds.northEast.lon;
|
||||
|
||||
statistics.global.atemp = this.global.atemp;
|
||||
statistics.global.hr = this.global.hr;
|
||||
statistics.global.cad = this.global.cad;
|
||||
statistics.global.power = this.global.power;
|
||||
|
||||
return statistics;
|
||||
}
|
||||
}
|
||||
|
||||
const earthRadius = 6371008.8;
|
||||
export function distance(
|
||||
coord1: TrackPoint | Coordinates,
|
||||
@@ -1911,11 +1656,15 @@ 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(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;
|
||||
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;
|
||||
}
|
||||
|
||||
export function getElevationDistanceFunction(statistics: GPXStatistics) {
|
||||
@@ -1926,9 +1675,9 @@ export function getElevationDistanceFunction(statistics: GPXStatistics) {
|
||||
if (point1.ele === undefined || point2.ele === undefined || point3.ele === undefined) {
|
||||
return 0;
|
||||
}
|
||||
let x1 = statistics.local.distance.total[point1._data.index] * 1000;
|
||||
let x2 = statistics.local.distance.total[point2._data.index] * 1000;
|
||||
let x3 = statistics.local.distance.total[point3._data.index] * 1000;
|
||||
let 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 y1 = point1.ele;
|
||||
let y2 = point2.ele;
|
||||
let y3 = point3.ele;
|
||||
@@ -1942,57 +1691,61 @@ export function getElevationDistanceFunction(statistics: GPXStatistics) {
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
start++;
|
||||
}
|
||||
while (
|
||||
end < points.length &&
|
||||
distance(points[i].getCoordinates(), points[end].getCoordinates()) <= distanceWindow
|
||||
) {
|
||||
accumulated += accumulate(end);
|
||||
let end = Math.min(i + 2, right);
|
||||
while (end < right && distance(i, end) <= window) {
|
||||
end++;
|
||||
}
|
||||
result[i] = compute(accumulated, start, end - 1);
|
||||
callback(compute(start, end - 1), i);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function distanceWindowSmoothingWithDistanceAccumulator(
|
||||
points: TrackPoint[],
|
||||
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,
|
||||
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,
|
||||
(index) => distance(points[index].getCoordinates(), points[index + 1].getCoordinates())
|
||||
callback
|
||||
);
|
||||
}
|
||||
|
||||
function timeWindowSmoothing(
|
||||
points: TrackPoint[],
|
||||
window: number,
|
||||
compute: (start: number, end: number) => number,
|
||||
callback: (value: number, index: number) => void
|
||||
): void {
|
||||
windowSmoothing(
|
||||
0,
|
||||
points.length,
|
||||
(index1, index2) =>
|
||||
points[index2].time?.getTime() - points[index1].time?.getTime() || 2 * window,
|
||||
window,
|
||||
compute,
|
||||
callback
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2044,14 +1797,14 @@ function withArtificialTimestamps(
|
||||
totalTime: number,
|
||||
lastPoint: TrackPoint | undefined,
|
||||
startTime: Date,
|
||||
slope: number[]
|
||||
statistics: GPXStatistics
|
||||
): TrackPoint[] {
|
||||
let weight = [];
|
||||
let totalWeight = 0;
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
let dist = distance(points[i].getCoordinates(), points[i + 1].getCoordinates());
|
||||
let w = dist * (0.5 + 1 / (1 + Math.exp(-0.2 * slope[i])));
|
||||
let w = dist * (0.5 + 1 / (1 + Math.exp(-0.2 * statistics.local.data[i].slope.at)));
|
||||
weight.push(w);
|
||||
totalWeight += w;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './gpx';
|
||||
export * from './statistics';
|
||||
export { Coordinates, LineStyleExtension, WaypointType } from './types';
|
||||
export { parseGPX, buildGPX } from './io';
|
||||
export * from './simplify';
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Coordinates } from './types';
|
||||
|
||||
export type SimplifiedTrackPoint = { point: TrackPoint; distance?: number };
|
||||
|
||||
const earthRadius = 6371008.8;
|
||||
|
||||
export function ramerDouglasPeucker(
|
||||
points: TrackPoint[],
|
||||
epsilon: number = 50,
|
||||
@@ -61,76 +59,56 @@ function ramerDouglasPeuckerRecursive(
|
||||
}
|
||||
|
||||
export function crossarcDistance(
|
||||
point1: TrackPoint,
|
||||
point2: TrackPoint,
|
||||
point1: TrackPoint | Coordinates,
|
||||
point2: TrackPoint | Coordinates,
|
||||
point3: TrackPoint | Coordinates
|
||||
): number {
|
||||
return crossarc(
|
||||
point1.getCoordinates(),
|
||||
point2.getCoordinates(),
|
||||
point1 instanceof TrackPoint ? point1.getCoordinates() : point1,
|
||||
point2 instanceof TrackPoint ? point2.getCoordinates() : point2,
|
||||
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 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.
|
||||
// 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).
|
||||
|
||||
const rad = Math.PI / 180;
|
||||
const lat1 = coord1.lat * rad;
|
||||
const lat2 = coord2.lat * rad;
|
||||
const lat3 = coord3.lat * rad;
|
||||
// Convert to meters using approximate scaling
|
||||
const metersPerLongitudeDegree = getMetersPerLongitudeDegree(coord1.lat);
|
||||
|
||||
const lon1 = coord1.lon * rad;
|
||||
const lon2 = coord2.lon * rad;
|
||||
const lon3 = coord3.lon * 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;
|
||||
|
||||
// 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);
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const segmentLengthSquared = dx * dx + dy * dy;
|
||||
|
||||
let diff = Math.abs(bear13 - bear12);
|
||||
if (diff > Math.PI) {
|
||||
diff = 2 * Math.PI - diff;
|
||||
if (segmentLengthSquared === 0) {
|
||||
// p1 and p2 are the same point
|
||||
return Math.sqrt((x3 - x1) * (x3 - x1) + (y3 - y1) * (y3 - y1));
|
||||
}
|
||||
|
||||
// Is relative bearing obtuse?
|
||||
if (diff > Math.PI / 2) {
|
||||
return dis13;
|
||||
}
|
||||
// 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 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)
|
||||
);
|
||||
// Return distance from p3 to the projected point
|
||||
return Math.sqrt((x3 - projX) * (x3 - projX) + (y3 - projY) * (y3 - projY));
|
||||
}
|
||||
|
||||
export function projectedPoint(
|
||||
@@ -146,56 +124,39 @@ export function projectedPoint(
|
||||
}
|
||||
|
||||
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
|
||||
// Calculates the point on the line defined by p1 and p2
|
||||
// Calculates the point on the line segment defined by p1 and p2
|
||||
// that is closest to the third point, p3.
|
||||
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
|
||||
// Uses simple planar geometry (ignores earth curvature).
|
||||
|
||||
const rad = Math.PI / 180;
|
||||
const lat1 = coord1.lat * rad;
|
||||
const lat2 = coord2.lat * rad;
|
||||
const lat3 = coord3.lat * rad;
|
||||
// Convert to meters using approximate scaling
|
||||
const metersPerLongitudeDegree = getMetersPerLongitudeDegree(coord1.lat);
|
||||
|
||||
const lon1 = coord1.lon * rad;
|
||||
const lon2 = coord2.lon * rad;
|
||||
const lon3 = coord3.lon * 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;
|
||||
|
||||
// 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);
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const segmentLengthSquared = dx * dx + dy * dy;
|
||||
|
||||
let diff = Math.abs(bear13 - bear12);
|
||||
if (diff > Math.PI) {
|
||||
diff = 2 * Math.PI - diff;
|
||||
}
|
||||
|
||||
// Is relative bearing obtuse?
|
||||
if (diff > Math.PI / 2) {
|
||||
if (segmentLengthSquared === 0) {
|
||||
// p1 and p2 are the same point
|
||||
return coord1;
|
||||
}
|
||||
|
||||
// Find the cross-track distance.
|
||||
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
|
||||
// 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 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)
|
||||
);
|
||||
// Find the closest point on the segment
|
||||
const projX = x1 + t * dx;
|
||||
const projY = y1 + t * dy;
|
||||
|
||||
return { lat: lat4 / rad, lon: lon4 / rad };
|
||||
}
|
||||
// Convert back to degrees
|
||||
return {
|
||||
lat: projY / metersPerLatitudeDegree,
|
||||
lon: projX / metersPerLongitudeDegree,
|
||||
};
|
||||
}
|
||||
|
||||
391
gpx/src/statistics.ts
Normal file
391
gpx/src/statistics.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { TrackPoint } from './gpx';
|
||||
import { Coordinates } from './types';
|
||||
|
||||
export class GPXGlobalStatistics {
|
||||
length: number;
|
||||
distance: {
|
||||
moving: number;
|
||||
total: number;
|
||||
};
|
||||
time: {
|
||||
start: Date | undefined;
|
||||
end: Date | undefined;
|
||||
moving: number;
|
||||
total: number;
|
||||
};
|
||||
speed: {
|
||||
moving: number;
|
||||
total: number;
|
||||
};
|
||||
elevation: {
|
||||
gain: number;
|
||||
loss: number;
|
||||
};
|
||||
bounds: {
|
||||
southWest: Coordinates;
|
||||
northEast: Coordinates;
|
||||
};
|
||||
atemp: {
|
||||
avg: number;
|
||||
count: number;
|
||||
};
|
||||
hr: {
|
||||
avg: number;
|
||||
count: number;
|
||||
};
|
||||
cad: {
|
||||
avg: number;
|
||||
count: number;
|
||||
};
|
||||
power: {
|
||||
avg: number;
|
||||
count: number;
|
||||
};
|
||||
extensions: Record<string, Record<string, number>>;
|
||||
|
||||
constructor() {
|
||||
this.length = 0;
|
||||
this.distance = {
|
||||
moving: 0,
|
||||
total: 0,
|
||||
};
|
||||
this.time = {
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
moving: 0,
|
||||
total: 0,
|
||||
};
|
||||
this.speed = {
|
||||
moving: 0,
|
||||
total: 0,
|
||||
};
|
||||
this.elevation = {
|
||||
gain: 0,
|
||||
loss: 0,
|
||||
};
|
||||
this.bounds = {
|
||||
southWest: {
|
||||
lat: 90,
|
||||
lon: 180,
|
||||
},
|
||||
northEast: {
|
||||
lat: -90,
|
||||
lon: -180,
|
||||
},
|
||||
};
|
||||
this.atemp = {
|
||||
avg: 0,
|
||||
count: 0,
|
||||
};
|
||||
this.hr = {
|
||||
avg: 0,
|
||||
count: 0,
|
||||
};
|
||||
this.cad = {
|
||||
avg: 0,
|
||||
count: 0,
|
||||
};
|
||||
this.power = {
|
||||
avg: 0,
|
||||
count: 0,
|
||||
};
|
||||
this.extensions = {};
|
||||
}
|
||||
|
||||
mergeWith(other: GPXGlobalStatistics): void {
|
||||
this.length += other.length;
|
||||
|
||||
this.distance.total += other.distance.total;
|
||||
this.distance.moving += other.distance.moving;
|
||||
|
||||
this.time.start =
|
||||
this.time.start !== undefined && other.time.start !== undefined
|
||||
? new Date(Math.min(this.time.start.getTime(), other.time.start.getTime()))
|
||||
: (this.time.start ?? other.time.start);
|
||||
this.time.end =
|
||||
this.time.end !== undefined && other.time.end !== undefined
|
||||
? new Date(Math.max(this.time.end.getTime(), other.time.end.getTime()))
|
||||
: (this.time.end ?? other.time.end);
|
||||
|
||||
this.time.total += other.time.total;
|
||||
this.time.moving += other.time.moving;
|
||||
|
||||
this.speed.moving =
|
||||
this.time.moving > 0 ? this.distance.moving / (this.time.moving / 3600) : 0;
|
||||
this.speed.total = this.time.total > 0 ? this.distance.total / (this.time.total / 3600) : 0;
|
||||
|
||||
this.elevation.gain += other.elevation.gain;
|
||||
this.elevation.loss += other.elevation.loss;
|
||||
|
||||
this.bounds.southWest.lat = Math.min(this.bounds.southWest.lat, other.bounds.southWest.lat);
|
||||
this.bounds.southWest.lon = Math.min(this.bounds.southWest.lon, other.bounds.southWest.lon);
|
||||
this.bounds.northEast.lat = Math.max(this.bounds.northEast.lat, other.bounds.northEast.lat);
|
||||
this.bounds.northEast.lon = Math.max(this.bounds.northEast.lon, other.bounds.northEast.lon);
|
||||
|
||||
this.atemp.avg =
|
||||
(this.atemp.count * this.atemp.avg + other.atemp.count * other.atemp.avg) /
|
||||
Math.max(1, this.atemp.count + other.atemp.count);
|
||||
this.atemp.count += other.atemp.count;
|
||||
this.hr.avg =
|
||||
(this.hr.count * this.hr.avg + other.hr.count * other.hr.avg) /
|
||||
Math.max(1, this.hr.count + other.hr.count);
|
||||
this.hr.count += other.hr.count;
|
||||
this.cad.avg =
|
||||
(this.cad.count * this.cad.avg + other.cad.count * other.cad.avg) /
|
||||
Math.max(1, this.cad.count + other.cad.count);
|
||||
this.cad.count += other.cad.count;
|
||||
this.power.avg =
|
||||
(this.power.count * this.power.avg + other.power.count * other.power.avg) /
|
||||
Math.max(1, this.power.count + other.power.count);
|
||||
this.power.count += other.power.count;
|
||||
|
||||
Object.keys(other.extensions).forEach((extension) => {
|
||||
if (this.extensions[extension] === undefined) {
|
||||
this.extensions[extension] = {};
|
||||
}
|
||||
Object.keys(other.extensions[extension]).forEach((value) => {
|
||||
if (this.extensions[extension][value] === undefined) {
|
||||
this.extensions[extension][value] = 0;
|
||||
}
|
||||
this.extensions[extension][value] += other.extensions[extension][value];
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class TrackPointLocalStatistics {
|
||||
distance: {
|
||||
moving: number;
|
||||
total: number;
|
||||
};
|
||||
time: {
|
||||
moving: number;
|
||||
total: number;
|
||||
};
|
||||
speed: number;
|
||||
elevation: {
|
||||
gain: number;
|
||||
loss: number;
|
||||
};
|
||||
slope: {
|
||||
at: number;
|
||||
segment: number;
|
||||
length: number;
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.distance = {
|
||||
moving: 0,
|
||||
total: 0,
|
||||
};
|
||||
this.time = {
|
||||
moving: 0,
|
||||
total: 0,
|
||||
};
|
||||
this.speed = 0;
|
||||
this.elevation = {
|
||||
gain: 0,
|
||||
loss: 0,
|
||||
};
|
||||
this.slope = {
|
||||
at: 0,
|
||||
segment: 0,
|
||||
length: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class GPXLocalStatistics {
|
||||
points: TrackPoint[];
|
||||
data: TrackPointLocalStatistics[];
|
||||
|
||||
constructor() {
|
||||
this.points = [];
|
||||
this.data = [];
|
||||
}
|
||||
}
|
||||
|
||||
export type TrackPointWithLocalStatistics = {
|
||||
trkpt: TrackPoint;
|
||||
} & TrackPointLocalStatistics;
|
||||
|
||||
export class GPXStatistics {
|
||||
global: GPXGlobalStatistics;
|
||||
local: GPXLocalStatistics;
|
||||
|
||||
constructor() {
|
||||
this.global = new GPXGlobalStatistics();
|
||||
this.local = new GPXLocalStatistics();
|
||||
}
|
||||
|
||||
sliced(start: number, end: number): GPXGlobalStatistics {
|
||||
if (start < 0) {
|
||||
start = 0;
|
||||
} else if (start >= this.global.length) {
|
||||
return new GPXGlobalStatistics();
|
||||
}
|
||||
|
||||
if (end < start) {
|
||||
return new GPXGlobalStatistics();
|
||||
} else if (end >= this.global.length) {
|
||||
end = this.global.length - 1;
|
||||
}
|
||||
|
||||
if (start === 0 && end === this.global.length - 1) {
|
||||
return this.global;
|
||||
}
|
||||
|
||||
let statistics = new GPXGlobalStatistics();
|
||||
|
||||
statistics.length = end - start + 1;
|
||||
|
||||
statistics.distance.total =
|
||||
this.local.data[end].distance.total - this.local.data[start].distance.total;
|
||||
statistics.distance.moving =
|
||||
this.local.data[end].distance.moving - this.local.data[start].distance.moving;
|
||||
|
||||
statistics.time.start = this.local.points[start].time;
|
||||
statistics.time.end = this.local.points[end].time;
|
||||
|
||||
statistics.time.total = this.local.data[end].time.total - this.local.data[start].time.total;
|
||||
statistics.time.moving =
|
||||
this.local.data[end].time.moving - this.local.data[start].time.moving;
|
||||
|
||||
statistics.speed.moving =
|
||||
statistics.time.moving > 0
|
||||
? statistics.distance.moving / (statistics.time.moving / 3600)
|
||||
: 0;
|
||||
statistics.speed.total =
|
||||
statistics.time.total > 0
|
||||
? statistics.distance.total / (statistics.time.total / 3600)
|
||||
: 0;
|
||||
|
||||
statistics.elevation.gain =
|
||||
this.local.data[end].elevation.gain - this.local.data[start].elevation.gain;
|
||||
statistics.elevation.loss =
|
||||
this.local.data[end].elevation.loss - this.local.data[start].elevation.loss;
|
||||
|
||||
statistics.bounds.southWest.lat = this.global.bounds.southWest.lat;
|
||||
statistics.bounds.southWest.lon = this.global.bounds.southWest.lon;
|
||||
statistics.bounds.northEast.lat = this.global.bounds.northEast.lat;
|
||||
statistics.bounds.northEast.lon = this.global.bounds.northEast.lon;
|
||||
|
||||
statistics.atemp = this.global.atemp;
|
||||
statistics.hr = this.global.hr;
|
||||
statistics.cad = this.global.cad;
|
||||
statistics.power = this.global.power;
|
||||
|
||||
return statistics;
|
||||
}
|
||||
}
|
||||
|
||||
export class GPXStatisticsGroup {
|
||||
private _statistics: GPXStatistics[];
|
||||
private _cumulative: GPXGlobalStatistics[];
|
||||
private _slice: [number, number] | null = null;
|
||||
global: GPXGlobalStatistics;
|
||||
|
||||
constructor() {
|
||||
this._statistics = [];
|
||||
this._cumulative = [new GPXGlobalStatistics()];
|
||||
this.global = new GPXGlobalStatistics();
|
||||
}
|
||||
|
||||
add(statistics: GPXStatistics | GPXStatisticsGroup): void {
|
||||
if (statistics instanceof GPXStatisticsGroup) {
|
||||
statistics._statistics.forEach((stats) => this._add(stats));
|
||||
} else {
|
||||
this._add(statistics);
|
||||
}
|
||||
}
|
||||
|
||||
_add(statistics: GPXStatistics): void {
|
||||
this._statistics.push(statistics);
|
||||
const cumulative = new GPXGlobalStatistics();
|
||||
cumulative.mergeWith(this._cumulative[this._cumulative.length - 1]);
|
||||
cumulative.mergeWith(statistics.global);
|
||||
this._cumulative.push(cumulative);
|
||||
this.global.mergeWith(statistics.global);
|
||||
}
|
||||
|
||||
sliced(start: number, end: number): GPXGlobalStatistics {
|
||||
let sliced = new GPXGlobalStatistics();
|
||||
for (let i = 0; i < this._statistics.length; i++) {
|
||||
const statistics = this._statistics[i];
|
||||
const cumulative = this._cumulative[i];
|
||||
if (start < cumulative.length + statistics.global.length && end >= cumulative.length) {
|
||||
const localStart = Math.max(0, start - cumulative.length);
|
||||
const localEnd = Math.min(statistics.global.length - 1, end - cumulative.length);
|
||||
sliced.mergeWith(statistics.sliced(localStart, localEnd));
|
||||
}
|
||||
}
|
||||
return sliced;
|
||||
}
|
||||
|
||||
getTrackPoint(index: number): TrackPointWithLocalStatistics | undefined {
|
||||
if (this._slice !== null) {
|
||||
index += this._slice[0];
|
||||
}
|
||||
for (let i = 0; i < this._statistics.length; i++) {
|
||||
const statistics = this._statistics[i];
|
||||
const cumulative = this._cumulative[i];
|
||||
if (index < cumulative.length + statistics.global.length) {
|
||||
return this._getTrackPoint(cumulative, statistics, index - cumulative.length);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
_getTrackPoint(
|
||||
cumulative: GPXGlobalStatistics,
|
||||
statistics: GPXStatistics,
|
||||
index: number
|
||||
): TrackPointWithLocalStatistics {
|
||||
const point = statistics.local.points[index];
|
||||
return {
|
||||
trkpt: point,
|
||||
distance: {
|
||||
moving: statistics.local.data[index].distance.moving + cumulative.distance.moving,
|
||||
total: statistics.local.data[index].distance.total + cumulative.distance.total,
|
||||
},
|
||||
time: {
|
||||
moving: statistics.local.data[index].time.moving + cumulative.time.moving,
|
||||
total: statistics.local.data[index].time.total + cumulative.time.total,
|
||||
},
|
||||
speed: statistics.local.data[index].speed,
|
||||
elevation: {
|
||||
gain: statistics.local.data[index].elevation.gain + cumulative.elevation.gain,
|
||||
loss: statistics.local.data[index].elevation.loss + cumulative.elevation.loss,
|
||||
},
|
||||
slope: {
|
||||
at: statistics.local.data[index].slope.at,
|
||||
segment: statistics.local.data[index].slope.segment,
|
||||
length: statistics.local.data[index].slope.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
forEachTrackPoint(
|
||||
callback: (
|
||||
point: TrackPoint,
|
||||
distance: number,
|
||||
speed: number,
|
||||
slope: { at: number; segment: number; length: number },
|
||||
index: number
|
||||
) => void
|
||||
): void {
|
||||
for (let i = 0; i < this._statistics.length; i++) {
|
||||
const statistics = this._statistics[i];
|
||||
const cumulative = this._cumulative[i];
|
||||
statistics.local.points.forEach((point, index) =>
|
||||
callback(
|
||||
point,
|
||||
cumulative.distance.total + statistics.local.data[index].distance.total,
|
||||
statistics.local.data[index].speed,
|
||||
statistics.local.data[index].slope,
|
||||
cumulative.length + index
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "gpx.studio",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
PUBLIC_MAPBOX_TOKEN=YOUR_MAPBOX_TOKEN
|
||||
PUBLIC_MAPTILER_KEY=YOUR_MAPTILER_KEY
|
||||
2
website/.gitignore
vendored
2
website/.gitignore
vendored
@@ -8,3 +8,5 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
static/*.webmanifest
|
||||
!static/en.manifest.webmanifest
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
|
||||
1302
website/package-lock.json
generated
1302
website/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,8 @@
|
||||
"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 . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
"lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .",
|
||||
"format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
@@ -23,15 +23,14 @@
|
||||
"@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.12.0",
|
||||
"bits-ui": "^2.14.4",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-svelte": "^3.9.1",
|
||||
@@ -46,6 +45,7 @@
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"svelte": "^5.33.18",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-dnd-action": "^0.9.65",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
"tailwind-variants": "^3.1.1",
|
||||
"tailwindcss": "^4.1.8",
|
||||
@@ -61,11 +61,10 @@
|
||||
"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",
|
||||
"@types/mapbox__sphericalmercator": "^1.2.3",
|
||||
"chart.js": "^4.4.9",
|
||||
"@maplibre/maplibre-gl-geocoder": "^1.9.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-zoom": "^2.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dexie": "^4.0.11",
|
||||
@@ -73,9 +72,8 @@
|
||||
"gpx": "file:../gpx",
|
||||
"immer": "^10.1.1",
|
||||
"jszip": "^3.10.1",
|
||||
"mapbox-gl": "^3.12.0",
|
||||
"mapillary-js": "^4.1.2",
|
||||
"png.js": "^0.2.1",
|
||||
"maplibre-gl": "^5.16.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"tailwind-merge": "^3.3.0"
|
||||
|
||||
@@ -1,124 +1,126 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%) /* <- Wrap in HSL */;
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
--primary: hsl(240 5.9% 10%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--destructive: hsl(0 72.2% 50.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--ring: hsl(240 10% 3.9%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(240 5.9% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
--background: hsl(0 0% 100%) /* <- Wrap in HSL */;
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
--primary: hsl(240 5.9% 10%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--destructive: hsl(0 72.2% 50.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--ring: hsl(240 10% 3.9%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(240 5.9% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
|
||||
--support: rgb(220 15 130);
|
||||
--link: rgb(0 110 180);
|
||||
|
||||
--radius: 0.5rem;
|
||||
--support: rgb(220 15 130);
|
||||
--link: rgb(0 110 180);
|
||||
--selection: hsl(240 4.8% 93%);
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.dark {
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--popover: hsl(240 10% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--card: hsl(240 10% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
--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);
|
||||
--support: rgb(255 110 190);
|
||||
--link: rgb(80 190 255);
|
||||
--selection: hsl(240 3.7% 22%);
|
||||
}
|
||||
|
||||
|
||||
@theme inline {
|
||||
/* Radius (for rounded-*) */
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
/* Colors */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-ring: var(--ring);
|
||||
--color-radius: var(--radius);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-support: var(--support);
|
||||
--color-link: var(--link);
|
||||
/* 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);
|
||||
|
||||
--breakpoint-xs: 540px;
|
||||
/* Colors */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-ring: var(--ring);
|
||||
--color-radius: var(--radius);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-support: var(--support);
|
||||
--color-link: var(--link);
|
||||
|
||||
--breakpoint-xs: 540px;
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ 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,7 +17,6 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
@@ -22,15 +22,18 @@ import {
|
||||
Binoculars,
|
||||
Toilet,
|
||||
} from 'lucide-static';
|
||||
import { type StyleSpecification } from 'mapbox-gl';
|
||||
import { type RasterDEMSourceSpecification, type StyleSpecification } from 'maplibre-gl';
|
||||
import ignFrTopo from './custom/ign-fr-topo.json';
|
||||
import 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 } = {
|
||||
mapboxOutdoors: 'mapbox://styles/mapbox/outdoors-v12',
|
||||
mapboxSatellite: 'mapbox://styles/mapbox/satellite-streets-v12',
|
||||
maptilerTopo: `https://api.maptiler.com/maps/topo-v4/style.json?key=${maptilerKeyPlaceHolder}`,
|
||||
maptilerOutdoors: `https://api.maptiler.com/maps/outdoor-v4/style.json?key=${maptilerKeyPlaceHolder}`,
|
||||
maptilerSatellite: `https://api.maptiler.com/maps/hybrid-v4/style.json?key=${maptilerKeyPlaceHolder}`,
|
||||
openStreetMap: {
|
||||
version: 8,
|
||||
sources: {
|
||||
@@ -145,18 +148,19 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
|
||||
swisstopoVector: 'https://vectortiles.geo.admin.ch/styles/ch.swisstopo.basemap.vt/style.json',
|
||||
swisstopoSatellite:
|
||||
'https://vectortiles.geo.admin.ch/styles/ch.swisstopo.imagerybasemap.vt/style.json',
|
||||
linz: 'https://basemaps.linz.govt.nz/v1/tiles/topographic/EPSG:3857/style/topographic.json?api=d01fbtg0ar23gctac5m0jgyy2ds',
|
||||
linz: 'https://basemaps.linz.govt.nz/v1/styles/topographic-v2.json?api=d01fbtg0ar23gctac5m0jgyy2ds',
|
||||
linzTopo: {
|
||||
version: 8,
|
||||
sources: {
|
||||
linzTopo: {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://tiles-cdn.koordinates.com/services;key=39a8b989633a4bef98bc0e065380454a/tiles/v4/layer=50767/EPSG:3857/{z}/{x}/{y}.png',
|
||||
'https://basemaps.linz.govt.nz/v1/tiles/topo-raster/WebMercatorQuad/{z}/{x}/{y}.webp?api=d01fbtg0ar23gctac5m0jgyy2ds',
|
||||
],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.linz.govt.nz/" target="_blank">LINZ</a>',
|
||||
maxzoom: 16,
|
||||
attribution:
|
||||
'© <a href="//www.linz.govt.nz/linz-copyright">LINZ CC BY 4.0</a> © <a href="//www.linz.govt.nz/data/linz-data/linz-basemaps/data-attribution">Imagery Basemap contributors</a>',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
@@ -186,8 +190,8 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
|
||||
},
|
||||
],
|
||||
},
|
||||
ignFrPlan: ignFrPlan,
|
||||
ignFrTopo: ignFrTopo,
|
||||
ignFrPlan: ignFrPlan as StyleSpecification,
|
||||
ignFrTopo: ignFrTopo as StyleSpecification,
|
||||
ignFrScan25: {
|
||||
version: 8,
|
||||
sources: {
|
||||
@@ -209,7 +213,7 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
|
||||
},
|
||||
],
|
||||
},
|
||||
ignFrSatellite: ignFrSatellite,
|
||||
ignFrSatellite: ignFrSatellite as StyleSpecification,
|
||||
ignEs: {
|
||||
version: 8,
|
||||
sources: {
|
||||
@@ -276,68 +280,6 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
|
||||
},
|
||||
],
|
||||
},
|
||||
swedenTopo: {
|
||||
version: 8,
|
||||
sources: {
|
||||
swedenTopoWMTS: {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://api.lantmateriet.se/open/topowebb-ccby/v1/wmts/token/1d54dd14-a28c-38a9-b6f3-b4ebfcc3c204/1.0.0/topowebb/default/3857/{z}/{y}/{x}.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
maxzoom: 14,
|
||||
attribution:
|
||||
'© <a href="https://www.lantmateriet.se" target="_blank">Lantmäteriet</a>',
|
||||
},
|
||||
swedenTopoWMS: {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://minkarta.lantmateriet.se/map/topowebb?REQUEST=GetMap&SERVICE=WMS&VERSION=1.1.1&FORMAT=image%2Fpng&STYLES=&TRANSPARENT=false&LAYERS=topowebbkartan&TILED=true&MAP_RESOLUTION=180&WIDTH=512&HEIGHT=512&SRS=EPSG%3A3857&BBOX={bbox-epsg-3857}',
|
||||
],
|
||||
tileSize: 512,
|
||||
minzoom: 14,
|
||||
maxzoom: 20,
|
||||
attribution:
|
||||
'© <a href="https://www.lantmateriet.se" target="_blank">Lantmäteriet</a>',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'swedenTopoWMTS',
|
||||
type: 'raster',
|
||||
source: 'swedenTopoWMTS',
|
||||
maxzoom: 14,
|
||||
},
|
||||
{
|
||||
id: 'swedenTopoWMS',
|
||||
type: 'raster',
|
||||
source: 'swedenTopoWMS',
|
||||
minzoom: 14,
|
||||
},
|
||||
],
|
||||
},
|
||||
swedenSatellite: {
|
||||
version: 8,
|
||||
sources: {
|
||||
swedenSatellite: {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://minkarta.lantmateriet.se/map/ortofoto?REQUEST=GetMap&SERVICE=WMS&VERSION=1.1.1&FORMAT=image%2Fpng&STYLES=&TRANSPARENT=false&LAYERS=Ortofoto_0.5%2COrtofoto_0.4%2COrtofoto_0.25%2COrtofoto_0.16&TILED=true&MAP_RESOLUTION=180&WIDTH=512&HEIGHT=512&SRS=EPSG%3A3857&BBOX={bbox-epsg-3857}',
|
||||
],
|
||||
tileSize: 512,
|
||||
maxzoom: 22,
|
||||
attribution:
|
||||
'© <a href="https://www.lantmateriet.se" target="_blank">Lantmäteriet</a>',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'swedenSatellite',
|
||||
type: 'raster',
|
||||
source: 'swedenSatellite',
|
||||
},
|
||||
],
|
||||
},
|
||||
finlandTopo: {
|
||||
version: 8,
|
||||
sources: {
|
||||
@@ -428,7 +370,43 @@ export const overlays: { [key: string]: string | StyleSpecification } = {
|
||||
},
|
||||
],
|
||||
},
|
||||
bikerouterGravel: bikerouterGravel,
|
||||
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">© 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: {
|
||||
@@ -798,8 +776,9 @@ export type LayerTreeType = { [key: string]: LayerTreeType | boolean };
|
||||
export const basemapTree: LayerTreeType = {
|
||||
basemaps: {
|
||||
world: {
|
||||
mapboxOutdoors: true,
|
||||
mapboxSatellite: true,
|
||||
maptilerTopo: true,
|
||||
maptilerOutdoors: true,
|
||||
maptilerSatellite: true,
|
||||
openStreetMap: true,
|
||||
openTopoMap: true,
|
||||
openHikingMap: true,
|
||||
@@ -833,10 +812,6 @@ export const basemapTree: LayerTreeType = {
|
||||
ignEs: true,
|
||||
ignEsSatellite: true,
|
||||
},
|
||||
sweden: {
|
||||
swedenTopo: true,
|
||||
swedenSatellite: true,
|
||||
},
|
||||
switzerland: {
|
||||
swisstopoRaster: true,
|
||||
swisstopoVector: true,
|
||||
@@ -864,8 +839,10 @@ export const overlayTree: LayerTreeType = {
|
||||
waymarkedTrailsHorseRiding: true,
|
||||
waymarkedTrailsWinter: true,
|
||||
},
|
||||
cyclOSMlite: true,
|
||||
bikerouterGravel: true,
|
||||
cyclOSMlite: true,
|
||||
mapterhornHillshade: true,
|
||||
openRailwayMap: true,
|
||||
},
|
||||
countries: {
|
||||
france: {
|
||||
@@ -901,6 +878,7 @@ export const overpassTree: LayerTreeType = {
|
||||
shower: true,
|
||||
shelter: true,
|
||||
barrier: true,
|
||||
cemetery: true,
|
||||
},
|
||||
tourism: {
|
||||
attraction: true,
|
||||
@@ -933,7 +911,7 @@ export const overpassTree: LayerTreeType = {
|
||||
};
|
||||
|
||||
// Default basemap used
|
||||
export const defaultBasemap = 'mapboxOutdoors';
|
||||
export const defaultBasemap = 'maptilerTopo';
|
||||
|
||||
// Default overlays used (none)
|
||||
export const defaultOverlays: LayerTreeType = {
|
||||
@@ -947,8 +925,10 @@ export const defaultOverlays: LayerTreeType = {
|
||||
waymarkedTrailsHorseRiding: false,
|
||||
waymarkedTrailsWinter: false,
|
||||
},
|
||||
cyclOSMlite: false,
|
||||
bikerouterGravel: false,
|
||||
cyclOSMlite: false,
|
||||
mapterhornHillshade: false,
|
||||
openRailwayMap: false,
|
||||
},
|
||||
countries: {
|
||||
france: {
|
||||
@@ -984,6 +964,7 @@ export const defaultOverpassQueries: LayerTreeType = {
|
||||
shower: false,
|
||||
shelter: false,
|
||||
barrier: false,
|
||||
cemetery: false,
|
||||
},
|
||||
tourism: {
|
||||
attraction: false,
|
||||
@@ -1019,8 +1000,9 @@ export const defaultOverpassQueries: LayerTreeType = {
|
||||
export const defaultBasemapTree: LayerTreeType = {
|
||||
basemaps: {
|
||||
world: {
|
||||
mapboxOutdoors: true,
|
||||
mapboxSatellite: true,
|
||||
maptilerTopo: true,
|
||||
maptilerOutdoors: true,
|
||||
maptilerSatellite: true,
|
||||
openStreetMap: true,
|
||||
openTopoMap: true,
|
||||
openHikingMap: true,
|
||||
@@ -1054,10 +1036,6 @@ export const defaultBasemapTree: LayerTreeType = {
|
||||
ignEs: false,
|
||||
ignEsSatellite: false,
|
||||
},
|
||||
sweden: {
|
||||
swedenTopo: false,
|
||||
swedenSatellite: false,
|
||||
},
|
||||
switzerland: {
|
||||
swisstopoRaster: false,
|
||||
swisstopoVector: false,
|
||||
@@ -1085,8 +1063,10 @@ export const defaultOverlayTree: LayerTreeType = {
|
||||
waymarkedTrailsHorseRiding: false,
|
||||
waymarkedTrailsWinter: false,
|
||||
},
|
||||
cyclOSMlite: false,
|
||||
bikerouterGravel: false,
|
||||
cyclOSMlite: false,
|
||||
mapterhornHillshade: false,
|
||||
openRailwayMap: false,
|
||||
},
|
||||
countries: {
|
||||
france: {
|
||||
@@ -1122,6 +1102,7 @@ export const defaultOverpassTree: LayerTreeType = {
|
||||
shower: false,
|
||||
shelter: false,
|
||||
barrier: false,
|
||||
cemetery: false,
|
||||
},
|
||||
tourism: {
|
||||
attraction: false,
|
||||
@@ -1160,7 +1141,7 @@ export type CustomLayer = {
|
||||
maxZoom: number;
|
||||
layerType: 'basemap' | 'overlay';
|
||||
resourceType: 'raster' | 'vector';
|
||||
value: string | {};
|
||||
value: string | maplibregl.StyleSpecification;
|
||||
};
|
||||
|
||||
type OverpassQueryData = {
|
||||
@@ -1168,9 +1149,7 @@ type OverpassQueryData = {
|
||||
svg: string;
|
||||
color: string;
|
||||
};
|
||||
tags:
|
||||
| Record<string, string | boolean | string[]>
|
||||
| Record<string, string | boolean | string[]>[];
|
||||
tags: Record<string, string | string[]> | Record<string, string | string[]>[];
|
||||
symbol?: string;
|
||||
};
|
||||
|
||||
@@ -1251,6 +1230,20 @@ 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,
|
||||
@@ -1287,7 +1280,25 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
|
||||
color: '#000000',
|
||||
},
|
||||
tags: {
|
||||
barrier: true,
|
||||
barrier: [
|
||||
'bar',
|
||||
'barrier_board',
|
||||
'block',
|
||||
'chain',
|
||||
'cycle_barrier',
|
||||
'gate',
|
||||
'hampshire_gate',
|
||||
'horse_stile',
|
||||
'kissing_gate',
|
||||
'lift_gate',
|
||||
'motorcycle_barrier',
|
||||
'sliding_beam',
|
||||
'sliding_gate',
|
||||
'stile',
|
||||
'swing_gate',
|
||||
'turnstile',
|
||||
'wicket_gate',
|
||||
],
|
||||
},
|
||||
},
|
||||
attraction: {
|
||||
@@ -1447,3 +1458,16 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
|
||||
symbol: 'Anchor',
|
||||
},
|
||||
};
|
||||
|
||||
export const terrainSources: { [key: string]: RasterDEMSourceSpecification } = {
|
||||
'maptiler-dem': {
|
||||
type: 'raster-dem',
|
||||
url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${maptilerKeyPlaceHolder}`,
|
||||
},
|
||||
mapterhorn: {
|
||||
type: 'raster-dem',
|
||||
url: 'https://tiles.mapterhorn.com/tilejson.json',
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultTerrainSource = 'maptiler-dem';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Landmark,
|
||||
Icon,
|
||||
Shell,
|
||||
Bike,
|
||||
Building,
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
DoorOpen,
|
||||
Trees,
|
||||
Fuel,
|
||||
Home,
|
||||
House,
|
||||
Info,
|
||||
TreeDeciduous,
|
||||
CircleParking,
|
||||
@@ -29,6 +28,7 @@ import {
|
||||
TriangleAlert,
|
||||
Anchor,
|
||||
Toilet,
|
||||
X,
|
||||
type IconProps,
|
||||
} from '@lucide/svelte';
|
||||
import {
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
DoorOpen as DoorOpenSvg,
|
||||
Trees as TreesSvg,
|
||||
Fuel as FuelSvg,
|
||||
Home as HomeSvg,
|
||||
House as HouseSvg,
|
||||
Info as InfoSvg,
|
||||
TreeDeciduous as TreeDeciduousSvg,
|
||||
CircleParking as CircleParkingSvg,
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
TriangleAlert as TriangleAlertSvg,
|
||||
Anchor as AnchorSvg,
|
||||
Toilet as ToiletSvg,
|
||||
X as XSvg,
|
||||
} from 'lucide-static';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
@@ -87,7 +88,11 @@ export const symbols: { [key: string]: Symbol } = {
|
||||
icon: ShoppingBasket,
|
||||
iconSvg: ShoppingBasketSvg,
|
||||
},
|
||||
crossing: { value: 'Crossing' },
|
||||
crossing: {
|
||||
value: 'Crossing',
|
||||
icon: X,
|
||||
iconSvg: XSvg,
|
||||
},
|
||||
department_store: {
|
||||
value: 'Department Store',
|
||||
icon: ShoppingBasket,
|
||||
@@ -95,7 +100,7 @@ export const symbols: { [key: string]: Symbol } = {
|
||||
},
|
||||
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
|
||||
exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg },
|
||||
lodge: { value: 'Lodge', icon: Home, iconSvg: HomeSvg },
|
||||
lodge: { value: 'Lodge', icon: House, iconSvg: HouseSvg },
|
||||
lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg },
|
||||
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
|
||||
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
|
||||
@@ -105,7 +110,7 @@ export const symbols: { [key: string]: Symbol } = {
|
||||
iconSvg: TrainFrontSvg,
|
||||
},
|
||||
hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg },
|
||||
house: { value: 'House', icon: Home, iconSvg: HomeSvg },
|
||||
house: { value: 'House', icon: House, iconSvg: HouseSvg },
|
||||
information: { value: 'Information', icon: Info, iconSvg: InfoSvg },
|
||||
park: { value: 'Park', icon: TreeDeciduous, iconSvg: TreeDeciduousSvg },
|
||||
parking_area: { value: 'Parking Area', icon: CircleParking, iconSvg: CircleParkingSvg },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import { AtSign, BookOpenText, Heart, Home, Map } from '@lucide/svelte';
|
||||
import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
</script>
|
||||
@@ -14,11 +14,11 @@
|
||||
<Logo class="h-8" width="153" />
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
>
|
||||
MIT © 2024 gpx.studio
|
||||
MIT © 2026 gpx.studio
|
||||
</Button>
|
||||
<LanguageSelect class="w-40 mt-3" />
|
||||
</div>
|
||||
@@ -27,15 +27,16 @@
|
||||
<span class="font-semibold">{i18n._('homepage.website')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href={getURLForLanguage(i18n.lang, '/')}
|
||||
>
|
||||
<Home size="16" />
|
||||
<House size="16" />
|
||||
{i18n._('homepage.home')}
|
||||
</Button>
|
||||
<Button
|
||||
data-sveltekit-reload
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href={getURLForLanguage(i18n.lang, '/app')}
|
||||
>
|
||||
<Map size="16" />
|
||||
@@ -43,7 +44,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href={getURLForLanguage(i18n.lang, '/help')}
|
||||
>
|
||||
<BookOpenText size="16" />
|
||||
@@ -54,7 +55,7 @@
|
||||
<span class="font-semibold">{i18n._('homepage.contact')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://www.reddit.com/r/gpxstudio/"
|
||||
target="_blank"
|
||||
>
|
||||
@@ -63,7 +64,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://facebook.com/gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
@@ -72,16 +73,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 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 text-muted-foreground"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="mailto:hello@gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
@@ -93,7 +85,7 @@
|
||||
<span class="font-semibold">{i18n._('homepage.contribute')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://ko-fi.com/gpxstudio"
|
||||
target="_blank"
|
||||
>
|
||||
@@ -102,7 +94,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://crowdin.com/project/gpxstudio"
|
||||
target="_blank"
|
||||
>
|
||||
@@ -111,7 +103,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://github.com/gpxstudio/gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
|
||||
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
|
||||
@@ -18,39 +18,39 @@
|
||||
orientation,
|
||||
panelSize,
|
||||
}: {
|
||||
gpxStatistics: Readable<GPXStatistics>;
|
||||
slicedGPXStatistics: Readable<[GPXStatistics, number, number] | undefined>;
|
||||
gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
panelSize: number;
|
||||
} = $props();
|
||||
|
||||
let statistics = $derived(
|
||||
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics
|
||||
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics.global
|
||||
);
|
||||
</script>
|
||||
|
||||
<Card.Root
|
||||
class="h-full {orientation === 'vertical'
|
||||
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
|
||||
: 'w-full'} border-none shadow-none p-0"
|
||||
? 'min-w-40 sm:min-w-44'
|
||||
: 'w-full h-10'} border-none shadow-none p-0 text-sm sm:text-base"
|
||||
>
|
||||
<Card.Content
|
||||
class="h-full flex {orientation === 'vertical'
|
||||
? 'flex-col justify-center'
|
||||
: 'flex-row w-full justify-between'} gap-4 p-0"
|
||||
: 'flex-row w-full justify-evenly'} gap-4 p-0"
|
||||
>
|
||||
<Tooltip label={i18n._('quantities.distance')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<Ruler size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.global.distance.total} type="distance" />
|
||||
<WithUnits value={statistics.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" />
|
||||
<WithUnits value={statistics.elevation.gain} type="elevation" />
|
||||
<MoveDownRight size="16" class="mx-1" />
|
||||
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
|
||||
<WithUnits value={statistics.elevation.loss} type="elevation" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{#if panelSize > 120 || orientation === 'horizontal'}
|
||||
@@ -64,13 +64,9 @@
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Zap size="16" class="mr-1" />
|
||||
<WithUnits
|
||||
value={statistics.global.speed.moving}
|
||||
type="speed"
|
||||
showUnits={false}
|
||||
/>
|
||||
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.global.speed.total} type="speed" />
|
||||
<WithUnits value={statistics.speed.total} type="speed" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
@@ -83,9 +79,9 @@
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Timer size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.global.time.moving} type="time" />
|
||||
<WithUnits value={statistics.time.moving} type="time" />
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.global.time.total} type="time" />
|
||||
<WithUnits value={statistics.time.total} type="time" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { CircleQuestionMark } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export let link: string | undefined = undefined;
|
||||
let {
|
||||
link,
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
link: string;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
|
||||
>
|
||||
<div class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {className}">
|
||||
<CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
||||
<div>
|
||||
<slot />
|
||||
{@render children()}
|
||||
{#if link}
|
||||
<a href={link} target="_blank" class="text-sm text-link hover:underline">
|
||||
{i18n._('menu.more')}
|
||||
|
||||
@@ -5,12 +5,18 @@
|
||||
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] {$$props.class ?? ''}" aria-label={i18n._('menu.language')}>
|
||||
<Select.Trigger class="min-w-[180px] {className}" aria-label={i18n._('menu.language')}>
|
||||
<Languages size="16" />
|
||||
<span class="ml-2 mr-auto">
|
||||
<span class="mr-auto">
|
||||
{languages[i18n.lang]}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
@@ -28,14 +34,3 @@
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
<!-- hidden links for svelte crawling -->
|
||||
<div class="hidden">
|
||||
{#if !page.url.pathname.includes('404')}
|
||||
{#each Object.entries(languages) as [lang, label]}
|
||||
<a href={getURLForLanguage(lang, page.url.pathname)}>
|
||||
{label}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { base } from '$app/paths';
|
||||
|
||||
export let iconOnly = false;
|
||||
export let company = 'gpx.studio';
|
||||
let {
|
||||
iconOnly = false,
|
||||
company = 'gpx.studio',
|
||||
...others
|
||||
}: {
|
||||
iconOnly?: boolean;
|
||||
company?: 'gpx.studio' | 'maptiler' | 'github' | 'crowdin' | 'facebook' | 'reddit';
|
||||
[key: string]: any;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if company === 'gpx.studio'}
|
||||
<img
|
||||
src="{base}/{iconOnly ? 'icon' : 'logo'}{mode.current === 'dark' ? '-dark' : ''}.svg"
|
||||
alt="Logo of gpx.studio."
|
||||
{...$$restProps}
|
||||
{...others}
|
||||
/>
|
||||
{:else if company === 'mapbox'}
|
||||
{:else if company === 'maptiler'}
|
||||
<img
|
||||
src="{base}/mapbox-logo-{mode.current === 'dark' ? 'white' : 'black'}.svg"
|
||||
alt="Logo of Mapbox."
|
||||
{...$$restProps}
|
||||
src="{base}/maptiler-logo{mode.current === 'dark' ? '-dark' : ''}.svg"
|
||||
alt="Logo of Maptiler."
|
||||
{...others}
|
||||
/>
|
||||
{:else if company === 'github'}
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
class="fill-foreground {others.class ?? ''}"
|
||||
><title>GitHub</title><path
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
/></svg
|
||||
@@ -33,7 +40,7 @@
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
class="fill-foreground {others.class ?? ''}"
|
||||
><title>Crowdin</title><path
|
||||
d="M16.119 17.793a2.619 2.619 0 0 1-1.667-.562c-.546-.436-1.004-1.09-1.018-1.858-.008-.388.414-.388.414-.388l1.018-.008c.332.008.43.47.445.586.128 1.04.717 1.495 1.168 1.702.273.123.204.513-.362.528zm-5.695-5.287L8.5 12.252c-.867-.214-.844-.982-.807-1.247a5.119 5.119 0 0 1 .814-2.125c.545-.804 1.303-1.508 2.29-2.073 1.856-1.074 4.45-1.673 7.31-1.673 2.09 0 4.256.27 4.29.27.197.025.328.213.333.437a.377.377 0 0 1-.355.393l-.92-.01c-2.902 0-4.968.394-6.506 1.248-1.527.837-2.57 2.117-3.287 4.012-.076.163-.335 1.12-1.24 1.022zm2.533 7.823c-1.44 0-2.797-.622-3.825-1.746-.87-.96-1.397-1.931-1.493-3.164-.06-.813.3-1.094.788-1.044l1.988.218c.45.092.75.34.825.854.397 2.736 2.122 3.814 3.15 4.046.18.042.292.157.283.365a.412.412 0 0 1-.322.398c-.458.074-.936.073-1.394.073zm-4.101 2.418a14.216 14.216 0 0 1-2.307-.214c-1.202-.214-2.208-.582-3.072-1.13C1.41 20.095.163 17.786.014 15.048c-.037-.65-.11-1.89 1.427-1.797.638.033 1.653.343 2.368.548.887.247 1.314.933 1.314 1.608 0 3.858 3.494 6.408 5.02 6.408.654 0 .414.701.127.779-.502.136-1.15.153-1.413.153zM3.525 11.419c-.605-.109-1.194-.358-1.768-.5C-.018 10.479.284 8.688.45 8.196c1.617-4.757 6.746-6.35 10.887-6.773 3.898-.4 7.978-.092 11.778.967.31.083 1.269.327.718.891-.35.358-1.7-.016-2.073-.041-2.23-.167-4.434-.192-6.656.15-2.349.357-4.768 1.099-6.71 2.665-.938.758-1.76 1.723-2.313 2.866-.144.3-.256.6-.354.9-.11.327-.47 1.91-2.215 1.6zm9.94.917c.332-1.488 1.81-3.848 6.385-3.686 1.05.033.57.749.052.731-2.586-.09-3.815 1.578-4.457 3.27-.219.546-.68.626-1.271.53-.415-.074-.866-.123-.71-.846Z"
|
||||
/></svg
|
||||
@@ -43,27 +50,17 @@
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
class="fill-foreground {others.class ?? ''}"
|
||||
><title>Facebook</title><path
|
||||
d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
|
||||
/></svg
|
||||
>
|
||||
{:else if company === 'x'}
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.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"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
class="fill-foreground {others.class ?? ''}"
|
||||
><title>Reddit</title><path
|
||||
d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"
|
||||
/></svg
|
||||
|
||||
@@ -51,11 +51,7 @@
|
||||
import { anySelectedLayer } from '$lib/components/map/layer-control/utils';
|
||||
import { defaultOverlays } from '$lib/assets/layers';
|
||||
import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte';
|
||||
import {
|
||||
allowedPastes,
|
||||
ListFileItem,
|
||||
ListTrackItem,
|
||||
} from '$lib/components/file-list/file-list';
|
||||
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/file-list';
|
||||
import Export from '$lib/components/export/Export.svelte';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
@@ -74,6 +70,8 @@
|
||||
import { copied, selection } from '$lib/logic/selection';
|
||||
import { allHidden } from '$lib/logic/hidden';
|
||||
import { boundsManager } from '$lib/logic/bounds';
|
||||
import { tick } from 'svelte';
|
||||
import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
|
||||
|
||||
const {
|
||||
distanceUnits,
|
||||
@@ -91,6 +89,9 @@
|
||||
routing,
|
||||
} = settings;
|
||||
|
||||
const canUndo = fileActionManager.canUndo;
|
||||
const canRedo = fileActionManager.canRedo;
|
||||
|
||||
function switchBasemaps() {
|
||||
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
|
||||
}
|
||||
@@ -143,11 +144,11 @@
|
||||
</Menubar.Item>
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
onclick={fileActions.deleteSelectedFiles}
|
||||
onclick={() => tick().then(fileActions.deleteSelectedFiles)}
|
||||
disabled={$selection.size == 0}
|
||||
>
|
||||
<FileX size="16" />
|
||||
{i18n._('menu.close')}
|
||||
{i18n._('menu.delete')}
|
||||
<Shortcut key="⌫" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Item
|
||||
@@ -155,7 +156,7 @@
|
||||
disabled={fileStateCollection.size == 0}
|
||||
>
|
||||
<FileX size="16" />
|
||||
{i18n._('menu.close_all')}
|
||||
{i18n._('menu.delete_all')}
|
||||
<Shortcut key="⌫" ctrl={true} shift={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Separator />
|
||||
@@ -183,18 +184,12 @@
|
||||
<span class="hidden md:block">{i18n._('menu.edit')}</span>
|
||||
</Menubar.Trigger>
|
||||
<Menubar.Content class="border-none">
|
||||
<Menubar.Item
|
||||
onclick={() => fileActionManager.undo()}
|
||||
disabled={!fileActionManager.canUndo}
|
||||
>
|
||||
<Menubar.Item onclick={() => fileActionManager.undo()} disabled={!$canUndo}>
|
||||
<Undo2 size="16" />
|
||||
{i18n._('menu.undo')}
|
||||
<Shortcut key="Z" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Item
|
||||
onclick={() => fileActionManager.redo()}
|
||||
disabled={!fileActionManager.canRedo}
|
||||
>
|
||||
<Menubar.Item onclick={() => fileActionManager.redo()} disabled={!$canRedo}>
|
||||
<Redo2 size="16" />
|
||||
{i18n._('menu.redo')}
|
||||
<Shortcut key="Z" ctrl={true} shift={true} />
|
||||
@@ -324,7 +319,7 @@
|
||||
$copied.length === 0 ||
|
||||
($selection.size > 0 &&
|
||||
!allowedPastes[$copied[0].level].includes(
|
||||
$selection.getSelected().pop()?.level
|
||||
$selection.getSelected().pop()!.level
|
||||
))}
|
||||
onclick={pasteSelection}
|
||||
>
|
||||
@@ -335,7 +330,7 @@
|
||||
{/if}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
onclick={fileActions.deleteSelection}
|
||||
onclick={() => tick().then(fileActions.deleteSelection)}
|
||||
disabled={$selection.size == 0}
|
||||
>
|
||||
<Trash2 size="16" />
|
||||
@@ -543,6 +538,7 @@
|
||||
let targetInput =
|
||||
e &&
|
||||
e.target &&
|
||||
e.target instanceof HTMLElement &&
|
||||
(e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.tagName === 'SELECT' ||
|
||||
@@ -656,7 +652,10 @@
|
||||
e.key === 'ArrowUp'
|
||||
) {
|
||||
if (!targetInput) {
|
||||
// updateSelectionFromKey(e.key === 'ArrowRight' || e.key === 'ArrowDown', e.shiftKey);
|
||||
selection.updateFromKey(
|
||||
e.key === 'ArrowRight' || e.key === 'ArrowDown',
|
||||
e.shiftKey
|
||||
);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
@@ -664,7 +663,7 @@
|
||||
on:dragover={(e) => e.preventDefault()}
|
||||
on:drop={(e) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
||||
loadFiles(e.dataTransfer.files);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -3,11 +3,18 @@
|
||||
import { Moon, Sun } from '@lucide/svelte';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
}: {
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={className}
|
||||
onclick={() => {
|
||||
setMode(mode.current === 'light' ? 'dark' : 'light');
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
|
||||
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
||||
import { BookOpenText, Home, Map } from '@lucide/svelte';
|
||||
import { BookOpenText, House, Map } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
</script>
|
||||
@@ -14,19 +14,32 @@
|
||||
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
|
||||
<Logo class="h-8 hidden sm:block" width="153" />
|
||||
</a>
|
||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage(i18n.lang, '/')}>
|
||||
<Home size="18" />
|
||||
<Button
|
||||
variant="link"
|
||||
class="text-base px-0 has-[>svg]:px-0"
|
||||
href={getURLForLanguage(i18n.lang, '/')}
|
||||
>
|
||||
<House size="18" />
|
||||
{i18n._('homepage.home')}
|
||||
</Button>
|
||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage(i18n.lang, '/app')}>
|
||||
<Button
|
||||
data-sveltekit-reload
|
||||
variant="link"
|
||||
class="text-base px-0 has-[>svg]:px-0"
|
||||
href={getURLForLanguage(i18n.lang, '/app')}
|
||||
>
|
||||
<Map size="18" />
|
||||
{i18n._('homepage.app')}
|
||||
</Button>
|
||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage(i18n.lang, '/help')}>
|
||||
<Button
|
||||
variant="link"
|
||||
class="text-base px-0 has-[>svg]:px-0"
|
||||
href={getURLForLanguage(i18n.lang, '/help')}
|
||||
>
|
||||
<BookOpenText size="18" />
|
||||
{i18n._('menu.help')}
|
||||
</Button>
|
||||
<AlgoliaDocSearch class="ml-auto" />
|
||||
<ModeSwitch class="hidden xs:block" />
|
||||
<ModeSwitch class="hidden xs:inline-flex" />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -34,9 +34,10 @@
|
||||
<Collapsible.Trigger class="w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full flex flex-row {side === 'right'
|
||||
size="icon"
|
||||
class="w-full flex flex-row gap-1 {side === 'right'
|
||||
? 'justify-between'
|
||||
: 'justify-start'} p-0 has-[>svg]:px-0 h-fit {nohover
|
||||
: 'justify-start pl-1'} h-fit {nohover
|
||||
? 'hover:bg-background'
|
||||
: ''} pointer-events-none"
|
||||
>
|
||||
@@ -60,9 +61,10 @@
|
||||
{:else}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full flex flex-row {side === 'right'
|
||||
size="icon"
|
||||
class="w-full flex flex-row gap-1 {side === 'right'
|
||||
? 'justify-between'
|
||||
: 'justify-start'} p-0 has-[>svg]:px-0 h-fit {nohover ? 'hover:bg-background' : ''}"
|
||||
: 'justify-start pl-1'} h-fit {nohover ? 'hover:bg-background' : ''}"
|
||||
>
|
||||
{#if side === 'left'}
|
||||
<Collapsible.Trigger>
|
||||
@@ -85,7 +87,6 @@
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Collapsible.Content>
|
||||
{@render props.content()}
|
||||
</Collapsible.Content>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import mapboxOutdoorsMap from '$lib/assets/img/home/mapbox-outdoors.png?enhanced';
|
||||
import maptilerTopoMap from '$lib/assets/img/home/maptiler-topo.png?enhanced';
|
||||
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
|
||||
</script>
|
||||
|
||||
<div class="relative h-80 aspect-square rounded-2xl shadow-xl overflow-clip">
|
||||
<enhanced:img src={mapboxOutdoorsMap} alt="Mapbox Outdoors map screenshot." class="absolute" />
|
||||
<enhanced:img src={maptilerTopoMap} alt="MapTiler Topo map screenshot." class="absolute" />
|
||||
<enhanced:img
|
||||
src={waymarkedMap}
|
||||
alt="Waymarked Trails map screenshot."
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
Construction,
|
||||
} from '@lucide/svelte';
|
||||
import type { Readable, Writable } from 'svelte/store';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
|
||||
@@ -28,12 +28,14 @@
|
||||
let {
|
||||
gpxStatistics,
|
||||
slicedGPXStatistics,
|
||||
hoveredPoint,
|
||||
additionalDatasets,
|
||||
elevationFill,
|
||||
showControls = true,
|
||||
}: {
|
||||
gpxStatistics: Readable<GPXStatistics>;
|
||||
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||
gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
hoveredPoint: Writable<Coordinates | null>;
|
||||
additionalDatasets: Writable<string[]>;
|
||||
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
||||
showControls?: boolean;
|
||||
@@ -41,12 +43,13 @@
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let overlay: HTMLCanvasElement;
|
||||
let elevationProfile: ElevationProfile;
|
||||
let elevationProfile: ElevationProfile | null = null;
|
||||
|
||||
onMount(() => {
|
||||
elevationProfile = new ElevationProfile(
|
||||
gpxStatistics,
|
||||
slicedGPXStatistics,
|
||||
hoveredPoint,
|
||||
additionalDatasets,
|
||||
elevationFill,
|
||||
canvas,
|
||||
@@ -55,11 +58,13 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
elevationProfile.destroy();
|
||||
if (elevationProfile) {
|
||||
elevationProfile.destroy();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-full grow min-w-0 relative py-2">
|
||||
<div class="h-full grow min-w-0 min-h-0 relative">
|
||||
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas>
|
||||
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas>
|
||||
{#if showControls}
|
||||
|
||||
@@ -14,11 +14,14 @@ import {
|
||||
getTemperatureWithUnits,
|
||||
getVelocityWithUnits,
|
||||
} from '$lib/units';
|
||||
import Chart from 'chart.js/auto';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import Chart, {
|
||||
type ChartEvent,
|
||||
type ChartOptions,
|
||||
type ScriptableLineSegmentContext,
|
||||
type TooltipItem,
|
||||
} from 'chart.js/auto';
|
||||
import { get, type Readable, type Writable } from 'svelte/store';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
|
||||
|
||||
@@ -27,22 +30,37 @@ 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<GPXStatistics>;
|
||||
private _slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||
private _gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
private _hoveredPoint: Writable<Coordinates | null>;
|
||||
private _additionalDatasets: Readable<string[]>;
|
||||
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
||||
|
||||
constructor(
|
||||
gpxStatistics: Readable<GPXStatistics>,
|
||||
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>,
|
||||
gpxStatistics: Readable<GPXStatisticsGroup>,
|
||||
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
|
||||
hoveredPoint: Writable<Coordinates | null>,
|
||||
additionalDatasets: Readable<string[]>,
|
||||
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
||||
canvas: HTMLCanvasElement,
|
||||
@@ -50,17 +68,12 @@ 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();
|
||||
@@ -90,7 +103,7 @@ export class ElevationProfile {
|
||||
}
|
||||
|
||||
initialize() {
|
||||
let options = {
|
||||
let options: ChartOptions<'line'> = {
|
||||
animation: false,
|
||||
parsing: false,
|
||||
maintainAspectRatio: false,
|
||||
@@ -98,8 +111,8 @@ export class ElevationProfile {
|
||||
x: {
|
||||
type: 'linear',
|
||||
ticks: {
|
||||
callback: function (value: number) {
|
||||
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
|
||||
callback: function (value: number | string) {
|
||||
return `${(value as number).toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
|
||||
},
|
||||
align: 'inner',
|
||||
maxRotation: 0,
|
||||
@@ -108,8 +121,8 @@ export class ElevationProfile {
|
||||
y: {
|
||||
type: 'linear',
|
||||
ticks: {
|
||||
callback: function (value: number) {
|
||||
return getElevationWithUnits(value, false);
|
||||
callback: function (value: number | string) {
|
||||
return getElevationWithUnits(value as number, false);
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -140,17 +153,13 @@ export class ElevationProfile {
|
||||
title: () => {
|
||||
return '';
|
||||
},
|
||||
label: (context: Chart.TooltipContext) => {
|
||||
let point = context.raw;
|
||||
label: (context: TooltipItem<'line'>) => {
|
||||
let point = context.raw as ElevationProfilePoint;
|
||||
if (context.datasetIndex === 0) {
|
||||
const map_ = get(map);
|
||||
if (map_ && this._marker) {
|
||||
if (this._dragging) {
|
||||
this._marker.remove();
|
||||
} else {
|
||||
this._marker.setLngLat(point.coordinates);
|
||||
this._marker.addTo(map_);
|
||||
}
|
||||
if (this._dragging) {
|
||||
this._hoveredPoint.set(null);
|
||||
} else {
|
||||
this._hoveredPoint.set(point.coordinates);
|
||||
}
|
||||
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
||||
} else if (context.datasetIndex === 1) {
|
||||
@@ -165,10 +174,10 @@ export class ElevationProfile {
|
||||
return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`;
|
||||
}
|
||||
},
|
||||
afterBody: (contexts: Chart.TooltipContext[]) => {
|
||||
afterBody: (contexts: TooltipItem<'line'>[]) => {
|
||||
let context = contexts.filter((context) => context.datasetIndex === 0);
|
||||
if (context.length === 0) return;
|
||||
let point = context[0].raw;
|
||||
let point = context[0].raw as ElevationProfilePoint;
|
||||
let slope = {
|
||||
at: point.slope.at.toFixed(1),
|
||||
segment: point.slope.segment.toFixed(1),
|
||||
@@ -227,6 +236,7 @@ export class ElevationProfile {
|
||||
onPanStart: () => {
|
||||
this._panning = true;
|
||||
this._slicedGPXStatistics.set(undefined);
|
||||
return true;
|
||||
},
|
||||
onPanComplete: () => {
|
||||
this._panning = false;
|
||||
@@ -238,13 +248,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(
|
||||
this._chart.getInitialScaleBounds().x.max /
|
||||
this._chart.options.plugins.zoom.limits.x.minRange -
|
||||
this._chart.getZoomLevel()
|
||||
) < 0.01
|
||||
Math.abs(maxZoom / this._chart.getZoomLevel()) < 0.01
|
||||
) {
|
||||
// Disable wheel pan if zoomed in to the max, and zooming in
|
||||
return false;
|
||||
@@ -262,7 +272,6 @@ export class ElevationProfile {
|
||||
},
|
||||
},
|
||||
},
|
||||
stacked: false,
|
||||
onResize: () => {
|
||||
this.updateOverlay();
|
||||
},
|
||||
@@ -270,7 +279,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: {
|
||||
@@ -291,12 +300,9 @@ export class ElevationProfile {
|
||||
{
|
||||
id: 'toggleMarker',
|
||||
events: ['mouseout'],
|
||||
afterEvent: (chart: Chart, args: { event: Chart.ChartEvent }) => {
|
||||
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
||||
if (args.event.type === 'mouseout') {
|
||||
const map_ = get(map);
|
||||
if (map_ && this._marker) {
|
||||
this._marker.remove();
|
||||
}
|
||||
this._hoveredPoint.set(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -305,7 +311,7 @@ export class ElevationProfile {
|
||||
|
||||
let startIndex = 0;
|
||||
let endIndex = 0;
|
||||
const getIndex = (evt) => {
|
||||
const getIndex = (evt: PointerEvent) => {
|
||||
if (!this._chart) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -323,22 +329,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 get(this._gpxStatistics).local.points.length - 1;
|
||||
return this._chart.data.datasets[0].data.length - 1;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let point = points.find((point) => point.element.raw);
|
||||
const point = points.find((point) => (point.element as any).raw);
|
||||
if (point) {
|
||||
return point.element.raw.index;
|
||||
return (point.element as any).raw.index;
|
||||
} else {
|
||||
return points[0].index;
|
||||
}
|
||||
};
|
||||
|
||||
let dragStarted = false;
|
||||
const onMouseDown = (evt) => {
|
||||
const onMouseDown = (evt: PointerEvent) => {
|
||||
if (evt.shiftKey) {
|
||||
// Panning interaction
|
||||
return;
|
||||
@@ -347,7 +353,7 @@ export class ElevationProfile {
|
||||
this._canvas.style.cursor = 'col-resize';
|
||||
startIndex = getIndex(evt);
|
||||
};
|
||||
const onMouseMove = (evt) => {
|
||||
const onMouseMove = (evt: PointerEvent) => {
|
||||
if (dragStarted) {
|
||||
this._dragging = true;
|
||||
endIndex = getIndex(evt);
|
||||
@@ -356,7 +362,7 @@ export class ElevationProfile {
|
||||
startIndex = endIndex;
|
||||
} else if (startIndex !== endIndex) {
|
||||
this._slicedGPXStatistics.set([
|
||||
get(this._gpxStatistics).slice(
|
||||
get(this._gpxStatistics).sliced(
|
||||
Math.min(startIndex, endIndex),
|
||||
Math.max(startIndex, endIndex)
|
||||
),
|
||||
@@ -367,7 +373,7 @@ export class ElevationProfile {
|
||||
}
|
||||
}
|
||||
};
|
||||
const onMouseUp = (evt) => {
|
||||
const onMouseUp = (evt: PointerEvent) => {
|
||||
dragStarted = false;
|
||||
this._dragging = false;
|
||||
this._canvas.style.cursor = '';
|
||||
@@ -386,85 +392,99 @@ 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: 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,
|
||||
};
|
||||
}),
|
||||
data: datasets[0],
|
||||
normalized: true,
|
||||
fill: 'start',
|
||||
order: 1,
|
||||
segment: {},
|
||||
};
|
||||
this._chart.data.datasets[1] = {
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: getConvertedVelocity(data.local.speed[index]),
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
data: datasets[1],
|
||||
normalized: true,
|
||||
yAxisID: 'yspeed',
|
||||
};
|
||||
this._chart.data.datasets[2] = {
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getHeartRate(),
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
data: datasets[2],
|
||||
normalized: true,
|
||||
yAxisID: 'yhr',
|
||||
};
|
||||
this._chart.data.datasets[3] = {
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getCadence(),
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
data: datasets[3],
|
||||
normalized: true,
|
||||
yAxisID: 'ycad',
|
||||
};
|
||||
this._chart.data.datasets[4] = {
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: getConvertedTemperature(point.getTemperature()),
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
data: datasets[4],
|
||||
normalized: true,
|
||||
yAxisID: 'yatemp',
|
||||
};
|
||||
this._chart.data.datasets[5] = {
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getPower(),
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
data: datasets[5],
|
||||
normalized: true,
|
||||
yAxisID: 'ypower',
|
||||
};
|
||||
this._chart.options.scales.x['min'] = 0;
|
||||
this._chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
|
||||
|
||||
this._chart.options.scales!.x!['min'] = 0;
|
||||
this._chart.options.scales!.x!['max'] = getConvertedDistance(
|
||||
data.global.distance.total,
|
||||
units.distance
|
||||
);
|
||||
|
||||
this.setVisibility();
|
||||
this.setFill();
|
||||
@@ -513,21 +533,24 @@ export class ElevationProfile {
|
||||
return;
|
||||
}
|
||||
const elevationFill = get(this._elevationFill);
|
||||
const dataset = this._chart.data.datasets[0];
|
||||
let segment: any = {};
|
||||
if (elevationFill === 'slope') {
|
||||
this._chart.data.datasets[0]['segment'] = {
|
||||
segment = {
|
||||
backgroundColor: this.slopeFillCallback,
|
||||
};
|
||||
} else if (elevationFill === 'surface') {
|
||||
this._chart.data.datasets[0]['segment'] = {
|
||||
segment = {
|
||||
backgroundColor: this.surfaceFillCallback,
|
||||
};
|
||||
} else if (elevationFill === 'highway') {
|
||||
this._chart.data.datasets[0]['segment'] = {
|
||||
segment = {
|
||||
backgroundColor: this.highwayFillCallback,
|
||||
};
|
||||
} else {
|
||||
this._chart.data.datasets[0]['segment'] = {};
|
||||
segment = {};
|
||||
}
|
||||
Object.assign(dataset, { segment });
|
||||
}
|
||||
|
||||
updateOverlay() {
|
||||
@@ -554,10 +577,12 @@ export class ElevationProfile {
|
||||
|
||||
const gpxStatistics = get(this._gpxStatistics);
|
||||
let startPixel = this._chart.scales.x.getPixelForValue(
|
||||
getConvertedDistance(gpxStatistics.local.distance.total[startIndex])
|
||||
getConvertedDistance(
|
||||
gpxStatistics.getTrackPoint(startIndex)?.distance.total ?? 0
|
||||
)
|
||||
);
|
||||
let endPixel = this._chart.scales.x.getPixelForValue(
|
||||
getConvertedDistance(gpxStatistics.local.distance.total[endIndex])
|
||||
getConvertedDistance(gpxStatistics.getTrackPoint(endIndex)?.distance.total ?? 0)
|
||||
);
|
||||
|
||||
selectionContext.fillRect(
|
||||
@@ -575,28 +600,29 @@ export class ElevationProfile {
|
||||
}
|
||||
}
|
||||
|
||||
slopeFillCallback(context) {
|
||||
return getSlopeColor(context.p0.raw.slope.segment);
|
||||
slopeFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
||||
const point = context.p0.raw as ElevationProfilePoint;
|
||||
return getSlopeColor(point.slope.segment);
|
||||
}
|
||||
|
||||
surfaceFillCallback(context) {
|
||||
return getSurfaceColor(context.p0.raw.extensions.surface);
|
||||
surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
||||
const point = context.p0.raw as ElevationProfilePoint;
|
||||
return getSurfaceColor(point.extensions.surface);
|
||||
}
|
||||
|
||||
highwayFillCallback(context) {
|
||||
highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
||||
const point = context.p0.raw as ElevationProfilePoint;
|
||||
return getHighwayColor(
|
||||
context.p0.raw.extensions.highway,
|
||||
context.p0.raw.extensions.sac_scale,
|
||||
context.p0.raw.extensions.mtb_scale
|
||||
point.extensions.highway,
|
||||
point.extensions.sac_scale,
|
||||
point.extensions.mtb_scale
|
||||
);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._chart) {
|
||||
this._chart.destroy();
|
||||
}
|
||||
if (this._marker) {
|
||||
this._marker.remove();
|
||||
this._chart = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,39 @@
|
||||
<script lang="ts">
|
||||
// import GPXLayers from '$lib/components/map/gpx-layer/GPXLayers.svelte';
|
||||
// import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
// import FileList from '$lib/components/file-list/FileList.svelte';
|
||||
// import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import GPXLayers from '$lib/components/map/gpx-layer/GPXLayers.svelte';
|
||||
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||
import FileList from '$lib/components/file-list/FileList.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import Map from '$lib/components/map/Map.svelte';
|
||||
import { map } from '$lib/components/map/map';
|
||||
// import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
|
||||
import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
|
||||
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
|
||||
import {
|
||||
gpxStatistics,
|
||||
slicedGPXStatistics,
|
||||
embedding,
|
||||
loadFile,
|
||||
updateGPXData,
|
||||
} from '$lib/stores';
|
||||
import { onDestroy, onMount, setContext } from 'svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
import type { GPXFile } from 'gpx';
|
||||
import { ListFileItem } from '$lib/components/file-list/file-list';
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
getFilesFromEmbeddingOptions,
|
||||
type EmbeddingOptions,
|
||||
} from './Embedding';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
import { browser } from '$app/environment';
|
||||
} from './embedding';
|
||||
import { setMode } from 'mode-watcher';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { gpxStatistics, hoveredPoint, 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,
|
||||
options = $bindable(),
|
||||
hash,
|
||||
hash = $bindable(),
|
||||
}: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props();
|
||||
|
||||
setContext('embedding', true);
|
||||
let additionalDatasets = writable<string[]>([]);
|
||||
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
|
||||
|
||||
const {
|
||||
currentBasemap,
|
||||
selectedBasemapTree,
|
||||
distanceUnits,
|
||||
velocityUnits,
|
||||
temperatureUnits,
|
||||
@@ -46,190 +42,77 @@
|
||||
directionMarkers,
|
||||
} = settings;
|
||||
|
||||
let prevSettings: {
|
||||
distanceMarkers: boolean;
|
||||
directionMarkers: boolean;
|
||||
distanceUnits: 'metric' | 'imperial' | 'nautical';
|
||||
velocityUnits: 'speed' | 'pace';
|
||||
temperatureUnits: 'celsius' | 'fahrenheit';
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
} = {
|
||||
distanceMarkers: false,
|
||||
directionMarkers: false,
|
||||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius',
|
||||
theme: 'system',
|
||||
};
|
||||
settings.initialize();
|
||||
|
||||
function applyOptions() {
|
||||
// fileObservers.update(($fileObservers) => {
|
||||
// $fileObservers.clear();
|
||||
// return $fileObservers;
|
||||
// });
|
||||
// let downloads: Promise<GPXFile | null>[] = [];
|
||||
// getFilesFromEmbeddingOptions(options).forEach((url) => {
|
||||
// downloads.push(
|
||||
// fetch(url)
|
||||
// .then((response) => response.blob())
|
||||
// .then((blob) => new File([blob], url.split('/').pop() ?? url))
|
||||
// .then(loadFile)
|
||||
// );
|
||||
// });
|
||||
// Promise.all(downloads).then((files) => {
|
||||
// let ids: string[] = [];
|
||||
// let bounds = {
|
||||
// southWest: {
|
||||
// lat: 90,
|
||||
// lon: 180,
|
||||
// },
|
||||
// northEast: {
|
||||
// lat: -90,
|
||||
// lon: -180,
|
||||
// },
|
||||
// };
|
||||
// fileObservers.update(($fileObservers) => {
|
||||
// files.forEach((file, index) => {
|
||||
// if (file === null) {
|
||||
// return;
|
||||
// }
|
||||
// let id = `gpx-${index}-embed`;
|
||||
// file._data.id = id;
|
||||
// let statistics = new GPXStatisticsTree(file);
|
||||
// $fileObservers.set(
|
||||
// id,
|
||||
// readable({
|
||||
// file,
|
||||
// statistics,
|
||||
// })
|
||||
// );
|
||||
// ids.push(id);
|
||||
// let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global
|
||||
// .bounds;
|
||||
// bounds.southWest.lat = Math.min(bounds.southWest.lat, fileBounds.southWest.lat);
|
||||
// bounds.southWest.lon = Math.min(bounds.southWest.lon, fileBounds.southWest.lon);
|
||||
// bounds.northEast.lat = Math.max(bounds.northEast.lat, fileBounds.northEast.lat);
|
||||
// bounds.northEast.lon = Math.max(bounds.northEast.lon, fileBounds.northEast.lon);
|
||||
// });
|
||||
// return $fileObservers;
|
||||
// });
|
||||
// $fileOrder = [...$fileOrder.filter((id) => !id.includes('embed')), ...ids];
|
||||
// selection.update(($selection) => {
|
||||
// $selection.clear();
|
||||
// ids.forEach((id) => {
|
||||
// $selection.toggle(new ListFileItem(id));
|
||||
// });
|
||||
// return $selection;
|
||||
// });
|
||||
// if (hash.length === 0) {
|
||||
// map.subscribe(($map) => {
|
||||
// if ($map) {
|
||||
// $map.fitBounds(
|
||||
// [
|
||||
// bounds.southWest.lon,
|
||||
// bounds.southWest.lat,
|
||||
// bounds.northEast.lon,
|
||||
// bounds.northEast.lat,
|
||||
// ],
|
||||
// {
|
||||
// padding: 80,
|
||||
// linear: true,
|
||||
// easing: () => 1,
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
// if (
|
||||
// options.basemap !== $currentBasemap &&
|
||||
// allowedEmbeddingBasemaps.includes(options.basemap)
|
||||
// ) {
|
||||
// $currentBasemap = options.basemap;
|
||||
// }
|
||||
// if (options.distanceMarkers !== $distanceMarkers) {
|
||||
// $distanceMarkers = options.distanceMarkers;
|
||||
// }
|
||||
// if (options.directionMarkers !== $directionMarkers) {
|
||||
// $directionMarkers = options.directionMarkers;
|
||||
// }
|
||||
// if (options.distanceUnits !== $distanceUnits) {
|
||||
// $distanceUnits = options.distanceUnits;
|
||||
// }
|
||||
// if (options.velocityUnits !== $velocityUnits) {
|
||||
// $velocityUnits = options.velocityUnits;
|
||||
// }
|
||||
// if (options.temperatureUnits !== $temperatureUnits) {
|
||||
// $temperatureUnits = options.temperatureUnits;
|
||||
// }
|
||||
// if (options.theme !== $mode) {
|
||||
// setMode(options.theme);
|
||||
// }
|
||||
let downloads: Promise<GPXFile | null>[] = getFilesFromEmbeddingOptions(options).map(
|
||||
(url) => {
|
||||
return fetch(url)
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => new File([blob], url.split('/').pop() ?? url))
|
||||
.then(loadFile);
|
||||
}
|
||||
);
|
||||
Promise.all(downloads).then((answers) => {
|
||||
const files = answers.filter((file) => file !== null) as GPXFile[];
|
||||
let ids: string[] = [];
|
||||
files.forEach((file, index) => {
|
||||
let id = `gpx-${index}-embed`;
|
||||
file._data.id = id;
|
||||
ids.push(id);
|
||||
});
|
||||
fileStateCollection.setEmbeddedFiles(files);
|
||||
$fileOrder = ids;
|
||||
selection.selectAll();
|
||||
});
|
||||
if (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;
|
||||
$velocityUnits = options.velocityUnits;
|
||||
$temperatureUnits = options.temperatureUnits;
|
||||
if (options.theme != 'system') {
|
||||
setMode(options.theme);
|
||||
}
|
||||
|
||||
additionalDatasets.set(
|
||||
[
|
||||
options.elevation.speed ? 'speed' : null,
|
||||
options.elevation.hr ? 'hr' : null,
|
||||
options.elevation.cad ? 'cad' : null,
|
||||
options.elevation.temp ? 'temp' : null,
|
||||
options.elevation.power ? 'power' : null,
|
||||
].filter((dataset) => dataset !== null)
|
||||
);
|
||||
elevationFill.set(options.elevation.fill == 'none' ? undefined : options.elevation.fill);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
prevSettings.distanceMarkers = distanceMarkers.value;
|
||||
prevSettings.directionMarkers = directionMarkers.value;
|
||||
prevSettings.distanceUnits = distanceUnits.value;
|
||||
prevSettings.velocityUnits = velocityUnits.value;
|
||||
prevSettings.temperatureUnits = temperatureUnits.value;
|
||||
prevSettings.theme = mode.current ?? 'system';
|
||||
});
|
||||
|
||||
// $: if (browser && options) {
|
||||
// applyOptions();
|
||||
// }
|
||||
|
||||
// $: if ($fileOrder) {
|
||||
// updateGPXData();
|
||||
// }
|
||||
|
||||
onDestroy(() => {
|
||||
if (distanceMarkers.value !== prevSettings.distanceMarkers) {
|
||||
distanceMarkers.value = prevSettings.distanceMarkers;
|
||||
}
|
||||
|
||||
if (directionMarkers.value !== prevSettings.directionMarkers) {
|
||||
directionMarkers.value = prevSettings.directionMarkers;
|
||||
}
|
||||
|
||||
if (distanceUnits.value !== prevSettings.distanceUnits) {
|
||||
distanceUnits.value = prevSettings.distanceUnits;
|
||||
}
|
||||
|
||||
if (velocityUnits.value !== prevSettings.velocityUnits) {
|
||||
velocityUnits.value = prevSettings.velocityUnits;
|
||||
}
|
||||
|
||||
if (temperatureUnits.value !== prevSettings.temperatureUnits) {
|
||||
temperatureUnits.value = prevSettings.temperatureUnits;
|
||||
}
|
||||
|
||||
if (mode.current !== prevSettings.theme) {
|
||||
setMode(prevSettings.theme);
|
||||
}
|
||||
|
||||
// $selection.clear();
|
||||
// $fileObservers.clear();
|
||||
fileOrder.value = fileOrder.value.filter((id) => !id.includes('embed'));
|
||||
$effect(() => {
|
||||
options;
|
||||
untrack(applyOptions);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
|
||||
<div class="grow relative">
|
||||
<Map
|
||||
class="h-full {fileStateCollection.files.size > 1 ? 'horizontal' : ''}"
|
||||
accessToken={options.token}
|
||||
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
|
||||
maptilerKey={options.key}
|
||||
geocoder={false}
|
||||
geolocate={false}
|
||||
geolocate={true}
|
||||
hash={useHash}
|
||||
/>
|
||||
<OpenIn files={options.files} ids={options.ids} />
|
||||
<!-- <LayerControl /> -->
|
||||
<!-- <GPXLayers /> -->
|
||||
{#if fileStateCollection.files.size > 1}
|
||||
<LayerControl />
|
||||
<GPXLayers />
|
||||
{#if $fileStateCollection.size > 1}
|
||||
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
|
||||
<!-- <FileList orientation="horizontal" /> -->
|
||||
<FileList orientation="horizontal" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -237,26 +120,21 @@
|
||||
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
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
panelSize={options.elevation.height}
|
||||
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
|
||||
/> -->
|
||||
/>
|
||||
{#if options.elevation.show}
|
||||
<!-- <ElevationProfile
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
additionalDatasets={[
|
||||
options.elevation.speed ? 'speed' : null,
|
||||
options.elevation.hr ? 'hr' : null,
|
||||
options.elevation.cad ? 'cad' : null,
|
||||
options.elevation.temp ? 'temp' : null,
|
||||
options.elevation.power ? 'power' : null,
|
||||
].filter((dataset) => dataset !== null)}
|
||||
elevationFill={options.elevation.fill}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
showControls={options.elevation.controls}
|
||||
/> -->
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,63 +18,61 @@
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
defaultEmbeddingOptions,
|
||||
getCleanedEmbeddingOptions,
|
||||
getDefaultEmbeddingOptions,
|
||||
} from './Embedding';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
getMergedEmbeddingOptions,
|
||||
} from './embedding';
|
||||
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
||||
import Embedding from './Embedding.svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { tick } from 'svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { base } from '$app/paths';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { mode } from 'mode-watcher';
|
||||
|
||||
let options = getDefaultEmbeddingOptions();
|
||||
options.token = 'YOUR_MAPBOX_TOKEN';
|
||||
options.files = [
|
||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx',
|
||||
];
|
||||
let options = $state(
|
||||
getMergedEmbeddingOptions(
|
||||
{
|
||||
key: 'YOUR_MAPTILER_KEY',
|
||||
theme: mode.current,
|
||||
},
|
||||
defaultEmbeddingOptions
|
||||
)
|
||||
);
|
||||
let files = $state(
|
||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
|
||||
);
|
||||
let driveIds = $state('');
|
||||
|
||||
let files = options.files[0];
|
||||
$: {
|
||||
let urls = files.split(',');
|
||||
urls = urls.filter((url) => url.length > 0);
|
||||
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
|
||||
options.files = urls;
|
||||
let iframeOptions = $derived(
|
||||
getMergedEmbeddingOptions(
|
||||
{
|
||||
key:
|
||||
options.key.length === 0 || options.key === 'YOUR_MAPTILER_KEY'
|
||||
? PUBLIC_MAPTILER_KEY
|
||||
: options.key,
|
||||
files: files.split(',').filter((url) => url.length > 0),
|
||||
ids: driveIds.split(',').filter((id) => id.length > 0),
|
||||
elevation: {
|
||||
fill: options.elevation.fill === 'none' ? undefined : options.elevation.fill,
|
||||
},
|
||||
},
|
||||
options
|
||||
)
|
||||
);
|
||||
|
||||
let manualCamera = $state(false);
|
||||
let zoom = $state('0');
|
||||
let lat = $state('0');
|
||||
let lon = $state('0');
|
||||
let bearing = $state('0');
|
||||
let pitch = $state('0');
|
||||
let hash = $derived(manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '');
|
||||
|
||||
$effect(() => {
|
||||
if (options.elevation.show || options.elevation.height) {
|
||||
map.resize();
|
||||
}
|
||||
}
|
||||
let driveIds = '';
|
||||
$: {
|
||||
let ids = driveIds.split(',');
|
||||
ids = ids.filter((id) => id.length > 0);
|
||||
if (JSON.stringify(ids) !== JSON.stringify(options.ids)) {
|
||||
options.ids = ids;
|
||||
}
|
||||
}
|
||||
|
||||
let manualCamera = false;
|
||||
|
||||
let zoom = '0';
|
||||
let lat = '0';
|
||||
let lon = '0';
|
||||
let bearing = '0';
|
||||
let pitch = '0';
|
||||
|
||||
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
|
||||
|
||||
$: iframeOptions =
|
||||
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
||||
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
|
||||
: options;
|
||||
|
||||
async function resizeMap() {
|
||||
if ($map) {
|
||||
await tick();
|
||||
$map.resize();
|
||||
}
|
||||
}
|
||||
|
||||
$: if (options.elevation.height || options.elevation.show) {
|
||||
resizeMap();
|
||||
}
|
||||
});
|
||||
|
||||
function updateCamera() {
|
||||
if ($map) {
|
||||
@@ -87,9 +85,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($map) {
|
||||
$map.on('moveend', updateCamera);
|
||||
}
|
||||
map.onLoad((map_) => {
|
||||
map_.on('moveend', updateCamera);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if ($map) {
|
||||
$map.off('moveend', updateCamera);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root id="embedding-playground">
|
||||
@@ -98,26 +102,16 @@
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<fieldset class="flex flex-col gap-3">
|
||||
<Label for="token">{i18n._('embedding.mapbox_token')}</Label>
|
||||
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
||||
<Label for="key">{i18n._('embedding.maptiler_key')}</Label>
|
||||
<Input id="key" type="text" class="h-8" bind:value={options.key} />
|
||||
<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>
|
||||
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
|
||||
<Label for="basemap">{i18n._('embedding.basemap')}</Label>
|
||||
<Select.Root
|
||||
selected={{
|
||||
value: options.basemap,
|
||||
label: i18n._(`layers.label.${options.basemap}`),
|
||||
}}
|
||||
onSelectedChange={(selected) => {
|
||||
if (selected?.value) {
|
||||
options.basemap = selected?.value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Root type="single" bind:value={options.basemap}>
|
||||
<Select.Trigger id="basemap" class="w-full h-8">
|
||||
<Select.Value />
|
||||
{i18n._(`layers.label.${options.basemap}`)}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||
{#each allowedEmbeddingBasemaps as basemap}
|
||||
@@ -145,23 +139,11 @@
|
||||
<span class="shrink-0">
|
||||
{i18n._('embedding.fill_by')}
|
||||
</span>
|
||||
<Select.Root
|
||||
selected={{ value: 'none', label: i18n._('embedding.none') }}
|
||||
onSelectedChange={(selected) => {
|
||||
let value = selected?.value;
|
||||
if (value === 'none') {
|
||||
options.elevation.fill = undefined;
|
||||
} else if (
|
||||
value === 'slope' ||
|
||||
value === 'surface' ||
|
||||
value === 'highway'
|
||||
) {
|
||||
options.elevation.fill = value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Root type="single" bind:value={options.elevation.fill}>
|
||||
<Select.Trigger class="grow h-8">
|
||||
<Select.Value />
|
||||
{options.elevation.fill !== 'none'
|
||||
? i18n._(`quantities.${options.elevation.fill}`)
|
||||
: i18n._('embedding.none')}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="slope">{i18n._('quantities.slope')}</Select.Item
|
||||
@@ -331,7 +313,7 @@
|
||||
{i18n._('embedding.preview')}
|
||||
</Label>
|
||||
<div class="relative h-[600px]">
|
||||
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
|
||||
<Embedding options={iframeOptions} bind:hash useHash={false} />
|
||||
</div>
|
||||
<Label>
|
||||
{i18n._('embedding.code')}
|
||||
@@ -339,7 +321,7 @@
|
||||
<pre
|
||||
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
||||
<code class="language-html">
|
||||
{`<iframe src="https://gpx.studio${base}/embed?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(options)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
|
||||
{`<iframe src="https://gpx.studio${base}/embed?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(iframeOptions)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
|
||||
</code>
|
||||
</pre>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
||||
import { basemaps } from '$lib/assets/layers';
|
||||
|
||||
export type EmbeddingOptions = {
|
||||
token: string;
|
||||
key: string;
|
||||
files: string[];
|
||||
ids: string[];
|
||||
basemap: string;
|
||||
@@ -10,7 +10,7 @@ export type EmbeddingOptions = {
|
||||
show: boolean;
|
||||
height: number;
|
||||
controls: boolean;
|
||||
fill: 'slope' | 'surface' | 'highway' | undefined;
|
||||
fill: 'slope' | 'surface' | 'highway' | 'none';
|
||||
speed: boolean;
|
||||
hr: boolean;
|
||||
cad: boolean;
|
||||
@@ -26,15 +26,15 @@ export type EmbeddingOptions = {
|
||||
};
|
||||
|
||||
export const defaultEmbeddingOptions = {
|
||||
token: '',
|
||||
key: '',
|
||||
files: [],
|
||||
ids: [],
|
||||
basemap: 'mapboxOutdoors',
|
||||
basemap: 'maptilerTopo',
|
||||
elevation: {
|
||||
show: true,
|
||||
height: 170,
|
||||
controls: true,
|
||||
fill: undefined,
|
||||
fill: 'none',
|
||||
speed: false,
|
||||
hr: false,
|
||||
cad: false,
|
||||
@@ -49,10 +49,6 @@ export const defaultEmbeddingOptions = {
|
||||
theme: 'system',
|
||||
};
|
||||
|
||||
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
|
||||
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
|
||||
}
|
||||
|
||||
export function getMergedEmbeddingOptions(
|
||||
options: any,
|
||||
defaultOptions: any = defaultEmbeddingOptions
|
||||
@@ -111,7 +107,7 @@ export function getURLForGoogleDriveFile(fileId: string): string {
|
||||
|
||||
export function convertOldEmbeddingOptions(options: URLSearchParams): any {
|
||||
let newOptions: any = {
|
||||
token: PUBLIC_MAPBOX_TOKEN,
|
||||
key: PUBLIC_MAPTILER_KEY,
|
||||
files: [],
|
||||
ids: [],
|
||||
};
|
||||
@@ -127,7 +123,7 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
|
||||
if (options.has('source')) {
|
||||
let basemap = options.get('source')!;
|
||||
if (basemap === 'satellite') {
|
||||
newOptions.basemap = 'mapboxSatellite';
|
||||
newOptions.basemap = 'maptilerSatellite';
|
||||
} else if (basemap === 'otm') {
|
||||
newOptions.basemap = 'openTopoMap';
|
||||
} else if (basemap === 'ohm') {
|
||||
@@ -21,7 +21,7 @@
|
||||
SquareActivity,
|
||||
} from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { GPXStatistics } from 'gpx';
|
||||
import { GPXGlobalStatistics } from 'gpx';
|
||||
import { ListRootItem } from '$lib/components/file-list/file-list';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
@@ -48,24 +48,24 @@
|
||||
extensions: false,
|
||||
};
|
||||
} else {
|
||||
let statistics = $gpxStatistics;
|
||||
let statistics = $gpxStatistics.global;
|
||||
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()));
|
||||
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()).global);
|
||||
}
|
||||
return acc;
|
||||
}, new GPXStatistics());
|
||||
}, new GPXGlobalStatistics());
|
||||
}
|
||||
return {
|
||||
time: statistics.global.time.total === 0,
|
||||
hr: statistics.global.hr.count === 0,
|
||||
cad: statistics.global.cad.count === 0,
|
||||
atemp: statistics.global.atemp.count === 0,
|
||||
power: statistics.global.power.count === 0,
|
||||
extensions: Object.keys(statistics.global.extensions).length === 0,
|
||||
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,
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -92,17 +92,17 @@
|
||||
class="fixed left-[50%] top-[50%] z-50 w-fit max-w-full translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-3 border bg-background p-3 shadow-lg rounded-md"
|
||||
>
|
||||
<div
|
||||
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-secondary"
|
||||
class="w-full flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 border rounded-md p-2 bg-secondary"
|
||||
>
|
||||
<span>⚠️</span>
|
||||
<span class="max-w-[80%] text-sm">
|
||||
<span class="w-12 shrink-0 text-center text-xl">⚠️</span>
|
||||
<span class="text-sm">
|
||||
{i18n._('menu.support_message')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full flex flex-row flex-wrap gap-2">
|
||||
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
|
||||
{i18n._('menu.support_button')}
|
||||
<span class="ml-2">🙏</span>
|
||||
<span>🙏</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -117,7 +117,7 @@
|
||||
exportState.current = ExportState.NONE;
|
||||
}}
|
||||
>
|
||||
<Download size="16" class="mr-1" />
|
||||
<Download size="16" />
|
||||
{#if $fileStateCollection.size === 1 || (exportState.current === ExportState.SELECTION && $selection.size === 1)}
|
||||
{i18n._('menu.download_file')}
|
||||
{:else}
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||
import FileListNode from './FileListNode.svelte';
|
||||
import { setContext } from 'svelte';
|
||||
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './file-list';
|
||||
import { onMount, setContext } from 'svelte';
|
||||
import { ListFileItem, ListLevel, ListRootItem } from './file-list';
|
||||
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { createFile, pasteSelection } from '$lib/logic/file-actions';
|
||||
import { selection, copied } from '$lib/logic/selection';
|
||||
import { allowedPastes } from './sortable-file-list';
|
||||
|
||||
let {
|
||||
orientation,
|
||||
@@ -27,36 +27,25 @@
|
||||
setContext('orientation', orientation);
|
||||
setContext('recursive', recursive);
|
||||
|
||||
const { treeFileView } = settings;
|
||||
|
||||
// treeFileView.subscribe(($vertical) => {
|
||||
// if ($vertical) {
|
||||
// selection.update(($selection) => {
|
||||
// $selection.forEach((item) => {
|
||||
// if ($selection.hasAnyChildren(item, false)) {
|
||||
// $selection.toggle(item);
|
||||
// }
|
||||
// });
|
||||
// return $selection;
|
||||
// });
|
||||
// } else {
|
||||
// selection.update(($selection) => {
|
||||
// $selection.forEach((item) => {
|
||||
// if (!(item instanceof ListFileItem)) {
|
||||
// $selection.toggle(item);
|
||||
// $selection.set(new ListFileItem(item.getFileId()), true);
|
||||
// }
|
||||
// });
|
||||
// return $selection;
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
onMount(() => {
|
||||
if (orientation === 'horizontal') {
|
||||
selection.update(($selection) => {
|
||||
$selection.forEach((item) => {
|
||||
if (!(item instanceof ListFileItem)) {
|
||||
$selection.toggle(item);
|
||||
$selection.set(new ListFileItem(item.getFileId()), true);
|
||||
}
|
||||
});
|
||||
return $selection;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<ScrollArea
|
||||
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
|
||||
{orientation}
|
||||
scrollbarXClasses={orientation === 'vertical' ? '' : 'mt-1 h-2'}
|
||||
scrollbarXClasses={orientation === 'vertical' ? '' : 'hidden'}
|
||||
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
|
||||
>
|
||||
<div
|
||||
@@ -71,7 +60,7 @@
|
||||
<ContextMenu.Trigger class="grow" />
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item onclick={createFile}>
|
||||
<Plus size="16" class="mr-1" />
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.new_file')}
|
||||
<Shortcut key="+" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
@@ -80,7 +69,7 @@
|
||||
onclick={() => selection.selectAll()}
|
||||
disabled={$fileStateCollection.size === 0}
|
||||
>
|
||||
<FileStack size="16" class="mr-1" />
|
||||
<FileStack size="16" />
|
||||
{i18n._('menu.select_all')}
|
||||
<Shortcut key="A" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
@@ -91,7 +80,7 @@
|
||||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
|
||||
onclick={pasteSelection}
|
||||
>
|
||||
<ClipboardPaste size="16" class="mr-1" />
|
||||
<ClipboardPaste size="16" />
|
||||
{i18n._('menu.paste')}
|
||||
<Shortcut key="V" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
|
||||
@@ -58,17 +58,11 @@
|
||||
|
||||
const { treeFileView } = settings;
|
||||
|
||||
function openIfSelectedChild() {
|
||||
if (collapsible && treeFileView.value && $selection.hasAnyChildren(item, false)) {
|
||||
$effect(() => {
|
||||
if (collapsible && $treeFileView && $selection.hasAnyChildren(item, false)) {
|
||||
collapsible.openNode();
|
||||
}
|
||||
}
|
||||
|
||||
if ($selection) {
|
||||
openIfSelectedChild();
|
||||
}
|
||||
|
||||
// afterUpdate(openIfSelectedChild);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if node instanceof Map}
|
||||
@@ -83,7 +77,7 @@
|
||||
<FileListNodeLabel {node} {item} {label} />
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="ml-2">
|
||||
<div class="ml-4">
|
||||
{#key node}
|
||||
<FileListNodeContent {node} {item} />
|
||||
{/key}
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
<script lang="ts" context="module">
|
||||
let dragging: Writable<ListLevel | null> = writable(null);
|
||||
|
||||
let updating = false;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
|
||||
import { getContext, onDestroy, onMount } from 'svelte';
|
||||
import Sortable from 'sortablejs/Sortable';
|
||||
import { get, writable, type Readable, type Writable } from 'svelte/store';
|
||||
import { type Readable } from 'svelte/store';
|
||||
import FileListNodeStore from './FileListNodeStore.svelte';
|
||||
import FileListNode from './FileListNode.svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListLevel,
|
||||
ListRootItem,
|
||||
ListWaypointsItem,
|
||||
allowedMoves,
|
||||
type ListItem,
|
||||
} from './file-list';
|
||||
import { isMac } from '$lib/utils';
|
||||
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||
import { ListFileItem, ListLevel, ListWaypointsItem, type ListItem } from './file-list';
|
||||
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { getFileIds, moveItems } from '$lib/logic/file-actions';
|
||||
import { allowedMoves, dragging, SortableFileList } from './sortable-file-list';
|
||||
|
||||
let {
|
||||
node,
|
||||
@@ -32,13 +17,13 @@
|
||||
node:
|
||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||
| GPXTreeElement<AnyGPXTreeElement>
|
||||
| Waypoint[]
|
||||
| Waypoint;
|
||||
item: ListItem;
|
||||
waypointRoot?: boolean;
|
||||
} = $props();
|
||||
|
||||
let container: HTMLElement;
|
||||
let elements: { [id: string]: HTMLElement } = {};
|
||||
let sortableLevel: ListLevel =
|
||||
node instanceof Map
|
||||
? ListLevel.FILE
|
||||
@@ -51,253 +36,32 @@
|
||||
: node instanceof Track
|
||||
? ListLevel.SEGMENT
|
||||
: ListLevel.WAYPOINT;
|
||||
let sortable: Sortable;
|
||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||
|
||||
let destroyed = false;
|
||||
let lastUpdateStart = 0;
|
||||
function updateToSelection(e) {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel));
|
||||
|
||||
lastUpdateStart = Date.now();
|
||||
setTimeout(() => {
|
||||
if (Date.now() - lastUpdateStart >= 40) {
|
||||
if (updating) {
|
||||
return;
|
||||
}
|
||||
|
||||
updating = true;
|
||||
// Sortable updates selection
|
||||
let changed = getChangedIds();
|
||||
if (changed.length > 0) {
|
||||
selection.update(($selection) => {
|
||||
$selection.clear();
|
||||
Object.entries(elements).forEach(([id, element]) => {
|
||||
$selection.set(
|
||||
item.extend(getRealId(id)),
|
||||
element.classList.contains('sortable-selected')
|
||||
);
|
||||
});
|
||||
|
||||
if (
|
||||
e.originalEvent &&
|
||||
!(
|
||||
e.originalEvent.ctrlKey ||
|
||||
e.originalEvent.metaKey ||
|
||||
e.originalEvent.shiftKey
|
||||
) &&
|
||||
($selection.size > 1 ||
|
||||
!$selection.has(item.extend(getRealId(changed[0]))))
|
||||
) {
|
||||
// Fix bug that sometimes causes a single select to be treated as a multi-select
|
||||
$selection.clear();
|
||||
$selection.set(item.extend(getRealId(changed[0])), true);
|
||||
}
|
||||
|
||||
return $selection;
|
||||
});
|
||||
}
|
||||
updating = false;
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function updateFromSelection() {
|
||||
if (destroyed || updating) {
|
||||
return;
|
||||
}
|
||||
updating = true;
|
||||
// Selection updates sortable
|
||||
let changed = getChangedIds();
|
||||
for (let id of changed) {
|
||||
let element = elements[id];
|
||||
if (element) {
|
||||
if ($selection.has(item.extend(id))) {
|
||||
Sortable.utils.select(element);
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
} else {
|
||||
Sortable.utils.deselect(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
updating = false;
|
||||
}
|
||||
|
||||
$: if ($selection) {
|
||||
updateFromSelection();
|
||||
}
|
||||
|
||||
function syncFileOrder(order: string[]) {
|
||||
if (!sortable || sortableLevel !== ListLevel.FILE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentOrder = sortable.toArray();
|
||||
if (currentOrder.length !== order.length) {
|
||||
sortable.sort(order);
|
||||
} else {
|
||||
for (let i = 0; i < currentOrder.length; i++) {
|
||||
if (currentOrder[i] !== order[i]) {
|
||||
sortable.sort(order);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { fileOrder } = settings;
|
||||
$effect(() => syncFileOrder(fileOrder.value));
|
||||
|
||||
function createSortable() {
|
||||
sortable = Sortable.create(container, {
|
||||
group: {
|
||||
name: sortableLevel,
|
||||
pull: allowedMoves[sortableLevel],
|
||||
put: true,
|
||||
},
|
||||
direction: orientation,
|
||||
forceAutoScrollFallback: true,
|
||||
multiDrag: true,
|
||||
multiDragKey: isMac() ? 'Meta' : 'Ctrl',
|
||||
avoidImplicitDeselect: true,
|
||||
onSelect: updateToSelection,
|
||||
onDeselect: updateToSelection,
|
||||
onStart: () => {
|
||||
dragging.set(sortableLevel);
|
||||
},
|
||||
onEnd: () => {
|
||||
dragging.set(null);
|
||||
},
|
||||
onSort: (e) => {
|
||||
if (sortableLevel === ListLevel.FILE) {
|
||||
let newFileOrder = sortable.toArray();
|
||||
if (newFileOrder.length !== fileOrder.value.length) {
|
||||
fileOrder.value = newFileOrder;
|
||||
} else {
|
||||
for (let i = 0; i < newFileOrder.length; i++) {
|
||||
if (newFileOrder[i] !== fileOrder.value[i]) {
|
||||
fileOrder.value = newFileOrder;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let fromItem = Sortable.get(e.from)._item;
|
||||
let toItem = Sortable.get(e.to)._item;
|
||||
|
||||
if (item === toItem && !(fromItem instanceof ListRootItem)) {
|
||||
// Event is triggered on source and destination list, only handle it once
|
||||
let fromItems = [];
|
||||
let toItems = [];
|
||||
|
||||
if (Sortable.get(e.from)._waypointRoot) {
|
||||
fromItems = [fromItem.extend('waypoints')];
|
||||
} else {
|
||||
let oldIndices: number[] =
|
||||
e.oldIndicies.length > 0
|
||||
? e.oldIndicies.map((i) => i.index)
|
||||
: [e.oldIndex];
|
||||
oldIndices = oldIndices.filter((i) => i >= 0);
|
||||
oldIndices.sort((a, b) => a - b);
|
||||
|
||||
fromItems = oldIndices.map((i) => fromItem.extend(i));
|
||||
}
|
||||
|
||||
if (Sortable.get(e.from)._waypointRoot && Sortable.get(e.to)._waypointRoot) {
|
||||
toItems = [toItem.extend('waypoints')];
|
||||
} else {
|
||||
if (Sortable.get(e.to)._waypointRoot) {
|
||||
toItem = toItem.extend('waypoints');
|
||||
}
|
||||
|
||||
let newIndices: number[] =
|
||||
e.newIndicies.length > 0
|
||||
? e.newIndicies.map((i) => i.index)
|
||||
: [e.newIndex];
|
||||
newIndices = newIndices.filter((i) => i >= 0);
|
||||
newIndices.sort((a, b) => a - b);
|
||||
|
||||
if (toItem instanceof ListRootItem) {
|
||||
let newFileIds = getFileIds(newIndices.length);
|
||||
toItems = newIndices.map((i, index) => {
|
||||
fileOrder.value.splice(i, 0, newFileIds[index]);
|
||||
return item.extend(newFileIds[index]);
|
||||
});
|
||||
} else {
|
||||
toItems = newIndices.map((i) => toItem.extend(i));
|
||||
}
|
||||
}
|
||||
|
||||
moveItems(fromItem, toItem, fromItems, toItems);
|
||||
}
|
||||
},
|
||||
});
|
||||
Object.defineProperty(sortable, '_item', {
|
||||
value: item,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(sortable, '_waypointRoot', {
|
||||
value: waypointRoot,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
let sortable: SortableFileList;
|
||||
|
||||
onMount(() => {
|
||||
createSortable();
|
||||
destroyed = false;
|
||||
sortable = new SortableFileList(
|
||||
container,
|
||||
node,
|
||||
item,
|
||||
waypointRoot,
|
||||
sortableLevel,
|
||||
orientation
|
||||
);
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
elements = {};
|
||||
container.childNodes.forEach((element) => {
|
||||
if (element instanceof HTMLElement) {
|
||||
let attr = element.getAttribute('data-id');
|
||||
if (attr) {
|
||||
if (node instanceof Map && !node.has(attr)) {
|
||||
element.remove();
|
||||
} else {
|
||||
elements[attr] = element;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
syncFileOrder();
|
||||
updateFromSelection();
|
||||
$effect(() => {
|
||||
if (sortable && node) {
|
||||
sortable.updateElements();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
destroyed = true;
|
||||
sortable.destroy();
|
||||
});
|
||||
|
||||
function getChangedIds() {
|
||||
let changed: (string | number)[] = [];
|
||||
Object.entries(elements).forEach(([id, element]) => {
|
||||
let realId = getRealId(id);
|
||||
let realItem = item.extend(realId);
|
||||
let inSelection = get(selection).has(realItem);
|
||||
let isSelected = element.classList.contains('sortable-selected');
|
||||
if (inSelection !== isSelected) {
|
||||
changed.push(realId);
|
||||
}
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
function getRealId(id: string | number) {
|
||||
return sortableLevel === ListLevel.FILE || sortableLevel === ListLevel.WAYPOINTS
|
||||
? id
|
||||
: parseInt(id);
|
||||
}
|
||||
|
||||
$: canDrop = $dragging !== null && allowedMoves[$dragging].includes(sortableLevel);
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -343,7 +107,7 @@
|
||||
|
||||
{#if node instanceof GPXFile && item instanceof ListFileItem}
|
||||
{#if !waypointRoot}
|
||||
<svelte:self {node} {item} waypointRoot={true} />
|
||||
<FileListNodeContent {node} {item} waypointRoot={true} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -357,20 +121,16 @@
|
||||
}
|
||||
|
||||
.vertical :global(button) {
|
||||
@apply hover:bg-muted;
|
||||
}
|
||||
|
||||
.vertical :global(.sortable-selected button) {
|
||||
@apply hover:bg-accent;
|
||||
@apply hover:bg-[var(--selection)];
|
||||
}
|
||||
|
||||
.vertical :global(.sortable-selected) {
|
||||
@apply bg-accent;
|
||||
@apply bg-[var(--selection)];
|
||||
}
|
||||
|
||||
.horizontal :global(button) {
|
||||
@apply bg-accent;
|
||||
@apply hover:bg-muted;
|
||||
@apply bg-[var(--selection)];
|
||||
@apply hover:bg-background;
|
||||
}
|
||||
|
||||
.horizontal :global(.sortable-selected button) {
|
||||
|
||||
@@ -17,31 +17,30 @@
|
||||
Maximize,
|
||||
Scissors,
|
||||
FileStack,
|
||||
FileX,
|
||||
} from '@lucide/svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListLevel,
|
||||
ListTrackItem,
|
||||
ListWaypointItem,
|
||||
allowedPastes,
|
||||
type ListItem,
|
||||
} from './file-list';
|
||||
import { getContext } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
|
||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||
import StyleDialog from './style/StyleDialog.svelte';
|
||||
import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte';
|
||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||
import { waypointPopup } from '$lib/components/map/gpx-layer/GPXLayerPopup';
|
||||
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 { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
||||
import { allowedPastes } from './sortable-file-list';
|
||||
|
||||
let {
|
||||
node,
|
||||
@@ -58,43 +57,32 @@
|
||||
|
||||
let singleSelection = $derived($selection.size === 1);
|
||||
|
||||
let nodeColors: string[] = []; /* $derived.by(() => {
|
||||
let nodeColors: string[] = $derived.by(() => {
|
||||
let colors: string[] = [];
|
||||
if (node && map.value) {
|
||||
if (node) {
|
||||
if (node instanceof GPXFile) {
|
||||
let defaultColor = undefined;
|
||||
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
defaultColor = layer.layerColor;
|
||||
}
|
||||
|
||||
let defaultColor = $gpxColors.get(item.getFileId());
|
||||
let style = node.getStyle(defaultColor);
|
||||
style.color.forEach((c) => {
|
||||
if (!colors.includes(c)) {
|
||||
colors.push(c);
|
||||
}
|
||||
});
|
||||
colors = style.color;
|
||||
} else if (node instanceof Track) {
|
||||
let style = node.getStyle();
|
||||
if (style) {
|
||||
if (
|
||||
style['gpx_style:color'] &&
|
||||
!nodeColors.includes(style['gpx_style:color'])
|
||||
) {
|
||||
nodeColors.push(style['gpx_style:color']);
|
||||
}
|
||||
if (
|
||||
style &&
|
||||
style['gpx_style:color'] &&
|
||||
!colors.includes(style['gpx_style:color'])
|
||||
) {
|
||||
colors.push(style['gpx_style:color']);
|
||||
}
|
||||
if (colors.length === 0) {
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
colors.push(layer.layerColor);
|
||||
let defaultColor = $gpxColors.get(item.getFileId());
|
||||
if (defaultColor) {
|
||||
colors.push(defaultColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
});*/
|
||||
});
|
||||
|
||||
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
|
||||
|
||||
@@ -117,8 +105,8 @@
|
||||
<ContextMenu.Root
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
if (!get(selection).has(item)) {
|
||||
selectItem(item);
|
||||
if (!$selection.has(item)) {
|
||||
selection.selectItem(item);
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -126,7 +114,7 @@
|
||||
<ContextMenu.Trigger class="grow truncate">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="relative w-full p-0 px-1 border-none overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
|
||||
class="relative w-full p-0 overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
|
||||
'vertical'
|
||||
? 'h-fit'
|
||||
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
|
||||
@@ -172,11 +160,11 @@
|
||||
}}
|
||||
onmouseenter={() => {
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
let file = getFile(item.getFileId());
|
||||
let layer = gpxLayers.getLayer(item.getFileId());
|
||||
let file = fileStateCollection.getFile(item.getFileId());
|
||||
if (layer && file) {
|
||||
let waypoint = file.wpt[item.getWaypointIndex()];
|
||||
if (waypoint) {
|
||||
if (waypoint && !waypoint._data.hidden) {
|
||||
waypointPopup?.setItem({
|
||||
item: waypoint,
|
||||
fileId: item.getFileId(),
|
||||
@@ -187,7 +175,7 @@
|
||||
}}
|
||||
onmouseleave={() => {
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
let layer = gpxLayers.getLayer(item.getFileId());
|
||||
if (layer) {
|
||||
waypointPopup?.setItem(null);
|
||||
}
|
||||
@@ -195,13 +183,13 @@
|
||||
}}
|
||||
>
|
||||
{#if item.level === ListLevel.SEGMENT}
|
||||
<Waypoints size="16" class="mr-1 shrink-0" />
|
||||
<Waypoints size="16" class="mx-1 shrink-0" />
|
||||
{:else if item.level === ListLevel.WAYPOINT}
|
||||
{#if symbolKey && symbols[symbolKey].icon}
|
||||
{@const SymbolIcon = symbols[symbolKey].icon}
|
||||
<SymbolIcon size="16" class="mr-1 shrink-0" />
|
||||
<SymbolIcon size="16" class="mx-1 shrink-0" />
|
||||
{:else}
|
||||
<MapPin size="16" class="mr-1 shrink-0" />
|
||||
<MapPin size="16" class="mx-1 shrink-0" />
|
||||
{/if}
|
||||
{/if}
|
||||
<span
|
||||
@@ -213,13 +201,10 @@
|
||||
</span>
|
||||
{#if hidden}
|
||||
<EyeOff
|
||||
size="12"
|
||||
class="shrink-0 mt-1 ml-1 {orientation === 'vertical'
|
||||
? 'mr-2'
|
||||
: ''} {item.level === ListLevel.SEGMENT ||
|
||||
item.level === ListLevel.WAYPOINT
|
||||
size="10"
|
||||
class="shrink-0 size-3.5 ml-1 {orientation === 'vertical'
|
||||
? 'mr-3'
|
||||
: ''}"
|
||||
: 'mt-0.5'}"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
@@ -231,12 +216,12 @@
|
||||
disabled={!singleSelection}
|
||||
onclick={() => (editMetadata.current = true)}
|
||||
>
|
||||
<Info size="16" class="mr-1" />
|
||||
<Info size="16" />
|
||||
{i18n._('menu.metadata.button')}
|
||||
<Shortcut key="I" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onclick={() => (editStyle.current = true)}>
|
||||
<PaintBucket size="16" class="mr-1" />
|
||||
<PaintBucket size="16" />
|
||||
{i18n._('menu.style.button')}
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
@@ -250,10 +235,10 @@
|
||||
}}
|
||||
>
|
||||
{#if $allHidden}
|
||||
<Eye size="16" class="mr-1" />
|
||||
<Eye size="16" />
|
||||
{i18n._('menu.unhide')}
|
||||
{:else}
|
||||
<EyeOff size="16" class="mr-1" />
|
||||
<EyeOff size="16" />
|
||||
{i18n._('menu.hide')}
|
||||
{/if}
|
||||
<Shortcut key="H" ctrl={true} />
|
||||
@@ -265,7 +250,7 @@
|
||||
disabled={!singleSelection}
|
||||
onclick={() => fileActions.addNewTrack(item.getFileId())}
|
||||
>
|
||||
<Plus size="16" class="mr-1" />
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.new_track')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
@@ -275,7 +260,7 @@
|
||||
onclick={() =>
|
||||
fileActions.addNewSegment(item.getFileId(), item.getTrackIndex())}
|
||||
>
|
||||
<Plus size="16" class="mr-1" />
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.new_segment')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
@@ -283,30 +268,30 @@
|
||||
{/if}
|
||||
{#if item.level !== ListLevel.WAYPOINTS}
|
||||
<ContextMenu.Item onclick={() => selection.selectAll()}>
|
||||
<FileStack size="16" class="mr-1" />
|
||||
<FileStack size="16" />
|
||||
{i18n._('menu.select_all')}
|
||||
<Shortcut key="A" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
<ContextMenu.Item onclick={() => boundsManager.centerMapOnSelection()}>
|
||||
<Maximize size="16" class="mr-1" />
|
||||
<Maximize size="16" />
|
||||
{i18n._('menu.center')}
|
||||
<Shortcut key="⏎" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onclick={fileActions.duplicateSelection}>
|
||||
<Copy size="16" class="mr-1" />
|
||||
<Copy size="16" />
|
||||
{i18n._('menu.duplicate')}
|
||||
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
|
||||
>
|
||||
<Shortcut key="D" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
{#if orientation === 'vertical'}
|
||||
<ContextMenu.Item onclick={() => selection.copySelection()}>
|
||||
<ClipboardCopy size="16" class="mr-1" />
|
||||
<ClipboardCopy size="16" />
|
||||
{i18n._('menu.copy')}
|
||||
<Shortcut key="C" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onclick={() => selection.cutSelection()}>
|
||||
<Scissors size="16" class="mr-1" />
|
||||
<Scissors size="16" />
|
||||
{i18n._('menu.cut')}
|
||||
<Shortcut key="X" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
@@ -316,20 +301,15 @@
|
||||
!allowedPastes[$copied[0].level].includes(item.level)}
|
||||
onclick={pasteSelection}
|
||||
>
|
||||
<ClipboardPaste size="16" class="mr-1" />
|
||||
<ClipboardPaste size="16" />
|
||||
{i18n._('menu.paste')}
|
||||
<Shortcut key="V" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onclick={fileActions.deleteSelection}>
|
||||
{#if item instanceof ListFileItem}
|
||||
<FileX size="16" class="mr-1" />
|
||||
{i18n._('menu.close')}
|
||||
{:else}
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{i18n._('menu.delete')}
|
||||
{/if}
|
||||
<Trash2 size="16" />
|
||||
{i18n._('menu.delete')}
|
||||
<Shortcut key="⌫" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
|
||||
@@ -7,24 +7,6 @@ export enum ListLevel {
|
||||
WAYPOINT,
|
||||
}
|
||||
|
||||
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||
[ListLevel.ROOT]: [],
|
||||
[ListLevel.FILE]: [ListLevel.FILE],
|
||||
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
|
||||
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
|
||||
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
};
|
||||
|
||||
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
||||
[ListLevel.ROOT]: [],
|
||||
[ListLevel.FILE]: [ListLevel.ROOT, ListLevel.FILE],
|
||||
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
|
||||
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
};
|
||||
|
||||
export abstract class ListItem {
|
||||
[x: string]: any;
|
||||
level: ListLevel;
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { Save } from '@lucide/svelte';
|
||||
import { ListFileItem, ListTrackItem, type ListItem } from '../file-list';
|
||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||
import { fileActionManager } from '$lib/logic/file-action-manager';
|
||||
|
||||
let {
|
||||
node,
|
||||
@@ -44,7 +44,7 @@
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger />
|
||||
<Popover.Trigger class="-mx-1" />
|
||||
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
||||
<Label for="name">{i18n._('menu.metadata.name')}</Label>
|
||||
<Input bind:value={name} id="name" class="font-semibold h-8" />
|
||||
@@ -53,7 +53,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
dbUtils.applyToFile(item.getFileId(), (file) => {
|
||||
fileActionManager.applyToFile(item.getFileId(), (file) => {
|
||||
if (item instanceof ListFileItem && node instanceof GPXFile) {
|
||||
file.metadata.name = name;
|
||||
file.metadata.desc = description;
|
||||
@@ -68,7 +68,7 @@
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
<Save size="16" class="mr-1" />
|
||||
<Save size="16" />
|
||||
{i18n._('menu.metadata.save')}
|
||||
</Button>
|
||||
</Popover.Content>
|
||||
|
||||
284
website/src/lib/components/file-list/sortable-file-list.ts
Normal file
284
website/src/lib/components/file-list/sortable-file-list.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { isMac } from '$lib/utils';
|
||||
import Sortable, { type Direction } from 'sortablejs/Sortable';
|
||||
import { ListItem, ListLevel, ListRootItem } from './file-list';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { getFileIds, moveItems } from '$lib/logic/file-actions';
|
||||
import { get, writable, type Readable } from 'svelte/store';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||
import type { AnyGPXTreeElement, GPXTreeElement, Waypoint } from 'gpx';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
const { fileOrder } = settings;
|
||||
|
||||
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||
[ListLevel.ROOT]: [],
|
||||
[ListLevel.FILE]: [ListLevel.FILE],
|
||||
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
|
||||
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
|
||||
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
};
|
||||
|
||||
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
||||
[ListLevel.ROOT]: [],
|
||||
[ListLevel.FILE]: [ListLevel.ROOT, ListLevel.FILE],
|
||||
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
|
||||
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
};
|
||||
|
||||
export const dragging = writable<ListLevel | null>(null);
|
||||
|
||||
export class SortableFileList {
|
||||
private _node:
|
||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||
| GPXTreeElement<AnyGPXTreeElement>
|
||||
| Waypoint[]
|
||||
| Waypoint;
|
||||
private _item: ListItem;
|
||||
private _sortableLevel: ListLevel;
|
||||
private _container: HTMLElement;
|
||||
private _sortable: Sortable | null = null;
|
||||
private _elements: { [id: string]: HTMLElement } = {};
|
||||
private _updatingSelection: boolean = false;
|
||||
private _unsubscribes: (() => void)[] = [];
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
node:
|
||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||
| GPXTreeElement<AnyGPXTreeElement>
|
||||
| Waypoint[]
|
||||
| Waypoint,
|
||||
item: ListItem,
|
||||
waypointRoot: boolean,
|
||||
sortableLevel: ListLevel,
|
||||
orientation: Direction
|
||||
) {
|
||||
this._node = node;
|
||||
this._item = item;
|
||||
this._sortableLevel = sortableLevel;
|
||||
this._container = container;
|
||||
this._sortable = Sortable.create(container, {
|
||||
group: {
|
||||
name: sortableLevel,
|
||||
pull: allowedMoves[sortableLevel],
|
||||
put: true,
|
||||
},
|
||||
direction: orientation,
|
||||
forceAutoScrollFallback: true,
|
||||
multiDrag: true,
|
||||
multiDragKey: isMac() ? 'Meta' : 'Ctrl',
|
||||
avoidImplicitDeselect: true,
|
||||
onSelect: (e: Sortable.SortableEvent) =>
|
||||
setTimeout(() => this.updateToSelection(e), 50),
|
||||
onDeselect: (e: Sortable.SortableEvent) =>
|
||||
setTimeout(() => this.updateToSelection(e), 50),
|
||||
onStart: () => dragging.set(sortableLevel),
|
||||
onEnd: () => dragging.set(null),
|
||||
onSort: (e: Sortable.SortableEvent) => this.onSort(e),
|
||||
});
|
||||
Object.defineProperty(this._sortable, '_item', {
|
||||
value: item,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(this._sortable, '_waypointRoot', {
|
||||
value: waypointRoot,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
this._unsubscribes.push(
|
||||
selection.subscribe(() => tick().then(() => this.updateFromSelection()))
|
||||
);
|
||||
this._unsubscribes.push(fileOrder.subscribe(() => this.updateFromFileOrder()));
|
||||
}
|
||||
|
||||
onSort(e: Sortable.SortableEvent) {
|
||||
this.updateToFileOrder();
|
||||
|
||||
const from = Sortable.get(e.from);
|
||||
const to = Sortable.get(e.to);
|
||||
|
||||
if (!from || !to) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fromItem = from._item;
|
||||
let toItem = to._item;
|
||||
|
||||
if (this._item === toItem && !(fromItem instanceof ListRootItem)) {
|
||||
// Event is triggered on source and destination list, only handle it once
|
||||
let fromItems = [];
|
||||
let toItems = [];
|
||||
|
||||
if (from._waypointRoot) {
|
||||
fromItems = [fromItem.extend('waypoints')];
|
||||
} else {
|
||||
let oldIndices: number[] =
|
||||
e.oldIndicies.length > 0 ? e.oldIndicies.map((i) => i.index) : [e.oldIndex];
|
||||
oldIndices = oldIndices.filter((i) => i >= 0);
|
||||
oldIndices.sort((a, b) => a - b);
|
||||
|
||||
fromItems = oldIndices.map((i) => fromItem.extend(i));
|
||||
}
|
||||
|
||||
if (from._waypointRoot && to._waypointRoot) {
|
||||
toItems = [toItem.extend('waypoints')];
|
||||
} else {
|
||||
if (to._waypointRoot) {
|
||||
toItem = toItem.extend('waypoints');
|
||||
}
|
||||
|
||||
let newIndices: number[] =
|
||||
e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex];
|
||||
newIndices = newIndices.filter((i) => i >= 0);
|
||||
newIndices.sort((a, b) => a - b);
|
||||
|
||||
if (toItem instanceof ListRootItem) {
|
||||
let newFileIds = getFileIds(newIndices.length);
|
||||
toItems = newIndices.map((i, index) => {
|
||||
get(fileOrder).splice(i, 0, newFileIds[index]);
|
||||
return this._item.extend(newFileIds[index]);
|
||||
});
|
||||
} else {
|
||||
toItems = newIndices.map((i) => toItem.extend(i));
|
||||
}
|
||||
}
|
||||
|
||||
moveItems(fromItem, toItem, fromItems, toItems);
|
||||
}
|
||||
}
|
||||
|
||||
updateFromSelection() {
|
||||
const changed = this.getChangedIds();
|
||||
if (changed.length === 0) {
|
||||
return;
|
||||
}
|
||||
const selection_ = get(selection);
|
||||
for (let id of changed) {
|
||||
let element = this._elements[id];
|
||||
if (element) {
|
||||
if (selection_.has(this._item.extend(id))) {
|
||||
Sortable.utils.select(element);
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
} else {
|
||||
Sortable.utils.deselect(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateToSelection(e: Sortable.SortableEvent) {
|
||||
if (!this._sortable) return;
|
||||
if (this._updatingSelection) return;
|
||||
this._updatingSelection = true;
|
||||
const changed = this.getChangedIds();
|
||||
if (changed.length == 0) {
|
||||
this._updatingSelection = false;
|
||||
return;
|
||||
}
|
||||
selection.update(($selection) => {
|
||||
$selection.clear();
|
||||
Object.entries(this._elements).forEach(([id, element]) => {
|
||||
$selection.set(
|
||||
this._item.extend(this.getRealId(id)),
|
||||
element.classList.contains('sortable-selected')
|
||||
);
|
||||
});
|
||||
|
||||
if (
|
||||
e.originalEvent &&
|
||||
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
|
||||
($selection.size > 1 ||
|
||||
!$selection.has(this._item.extend(this.getRealId(changed[0]))))
|
||||
) {
|
||||
// Fix bug that sometimes causes a single select to be treated as a multi-select
|
||||
$selection.clear();
|
||||
$selection.set(this._item.extend(this.getRealId(changed[0])), true);
|
||||
}
|
||||
return $selection;
|
||||
});
|
||||
this._updatingSelection = false;
|
||||
}
|
||||
|
||||
updateFromFileOrder() {
|
||||
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileOrder_ = get(fileOrder);
|
||||
const sortableOrder = this._sortable.toArray();
|
||||
|
||||
if (
|
||||
fileOrder_.length !== sortableOrder.length ||
|
||||
fileOrder_.some((value, index) => value !== sortableOrder[index])
|
||||
) {
|
||||
this._sortable.sort(fileOrder_);
|
||||
}
|
||||
}
|
||||
|
||||
updateToFileOrder() {
|
||||
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileOrder_ = get(fileOrder);
|
||||
const sortableOrder = this._sortable.toArray();
|
||||
|
||||
if (
|
||||
fileOrder_.length !== sortableOrder.length ||
|
||||
fileOrder_.some((value, index) => value !== sortableOrder[index])
|
||||
) {
|
||||
fileOrder.set(sortableOrder);
|
||||
}
|
||||
}
|
||||
|
||||
updateElements() {
|
||||
this._elements = {};
|
||||
this._container.childNodes.forEach((element) => {
|
||||
if (element instanceof HTMLElement) {
|
||||
let attr = element.getAttribute('data-id');
|
||||
if (attr) {
|
||||
if (this._node instanceof Map && !this._node.has(attr)) {
|
||||
element.remove();
|
||||
} else {
|
||||
this._elements[attr] = element;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._sortable = null;
|
||||
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
this._unsubscribes = [];
|
||||
}
|
||||
|
||||
getChangedIds() {
|
||||
let changed: (string | number)[] = [];
|
||||
const selection_ = get(selection);
|
||||
Object.entries(this._elements).forEach(([id, element]) => {
|
||||
let realId = this.getRealId(id);
|
||||
let realItem = this._item.extend(realId);
|
||||
let inSelection = selection_.has(realItem);
|
||||
let isSelected = element.classList.contains('sortable-selected');
|
||||
if (inSelection !== isSelected) {
|
||||
changed.push(realId);
|
||||
}
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
getRealId(id: string | number) {
|
||||
return this._sortableLevel === ListLevel.FILE || this._sortableLevel === ListLevel.WAYPOINTS
|
||||
? id
|
||||
: parseInt(id as string);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { dbUtils, getFile, settings } from '$lib/db';
|
||||
import { Save } from '@lucide/svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
@@ -12,10 +11,14 @@
|
||||
type ListItem,
|
||||
} from '$lib/components/file-list/file-list';
|
||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||
import { selection } from '../Selection';
|
||||
import { gpxLayers } from '$lib/stores';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import type { LineStyleExtension } from 'gpx';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||
import { untrack } from 'svelte';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
|
||||
let {
|
||||
item,
|
||||
@@ -40,8 +43,8 @@
|
||||
|
||||
$selection.forEach((item) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
let file = fileStateCollection.getFile(item.getFileId());
|
||||
let layer = gpxLayers.getLayer(item.getFileId());
|
||||
if (file && layer) {
|
||||
let style = file.getStyle();
|
||||
color = layer.layerColor;
|
||||
@@ -53,8 +56,8 @@
|
||||
}
|
||||
}
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
let file = fileStateCollection.getFile(item.getFileId());
|
||||
let layer = gpxLayers.getLayer(item.getFileId());
|
||||
if (file && layer) {
|
||||
color = layer.layerColor;
|
||||
let track = file.trk[item.getTrackIndex()];
|
||||
@@ -81,7 +84,7 @@
|
||||
|
||||
$effect(() => {
|
||||
if ($selection && open) {
|
||||
setStyleInputs();
|
||||
untrack(() => setStyleInputs());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -102,9 +105,9 @@
|
||||
if (widthChanged) {
|
||||
style['gpx_style:width'] = width;
|
||||
}
|
||||
dbUtils.setStyleToSelection(style);
|
||||
fileActions.setStyleToSelection(style);
|
||||
|
||||
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
||||
if (item instanceof ListFileItem && $selection.size === fileStateCollection.size) {
|
||||
if (style['gpx_style:opacity']) {
|
||||
$defaultOpacity = style['gpx_style:opacity'];
|
||||
}
|
||||
@@ -118,7 +121,7 @@
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger />
|
||||
<Popover.Trigger class="-mx-1" />
|
||||
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||
{i18n._('menu.style.color')}
|
||||
@@ -161,7 +164,7 @@
|
||||
disabled={!colorChanged && !opacityChanged && !widthChanged}
|
||||
onclick={applyStyle}
|
||||
>
|
||||
<Save size="16" class="mr-1" />
|
||||
<Save size="16" />
|
||||
{i18n._('menu.metadata.save')}
|
||||
</Button>
|
||||
</Popover.Content>
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
|
||||
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: {
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
<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 {
|
||||
accessToken = PUBLIC_MAPBOX_TOKEN,
|
||||
maptilerKey = PUBLIC_MAPTILER_KEY,
|
||||
geolocate = true,
|
||||
geocoder = true,
|
||||
hash = true,
|
||||
class: className = '',
|
||||
}: {
|
||||
accessToken?: string;
|
||||
maptilerKey?: string;
|
||||
geolocate?: boolean;
|
||||
geocoder?: boolean;
|
||||
hash?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
mapboxgl.accessToken = accessToken;
|
||||
|
||||
let webgl2Supported = $state(true);
|
||||
let embeddedApp = $state(false);
|
||||
|
||||
@@ -48,7 +43,7 @@
|
||||
language = 'en';
|
||||
}
|
||||
|
||||
map.init(PUBLIC_MAPBOX_TOKEN, language, hash, geocoder, geolocate);
|
||||
map.init(maptilerKey, language, hash, geocoder, geolocate);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -81,21 +76,21 @@
|
||||
<style lang="postcss">
|
||||
@reference "../../../app.css";
|
||||
|
||||
div :global(.mapboxgl-map) {
|
||||
div :global(.maplibregl-map) {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
|
||||
div :global(.maplibregl-ctrl-top-right > .maplibregl-ctrl) {
|
||||
@apply shadow-md;
|
||||
@apply bg-background;
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-icon) {
|
||||
div :global(.maplibregl-ctrl-icon) {
|
||||
@apply dark:brightness-[4.7];
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder) {
|
||||
div :global(.maplibregl-ctrl-geocoder) {
|
||||
@apply flex;
|
||||
@apply flex-row;
|
||||
@apply w-fit;
|
||||
@@ -110,36 +105,45 @@
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
|
||||
div :global(.maplibregl-ctrl-geocoder .suggestions > li > a) {
|
||||
@apply text-foreground;
|
||||
@apply hover:text-accent-foreground;
|
||||
@apply hover:bg-accent;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
|
||||
div :global(.maplibregl-ctrl-geocoder .suggestions > .active > a) {
|
||||
@apply bg-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--button) {
|
||||
div :global(.maplibregl-ctrl-geocoder--button) {
|
||||
@apply bg-transparent;
|
||||
@apply hover:bg-transparent;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--icon) {
|
||||
div :global(.maplibregl-ctrl-geocoder--icon) {
|
||||
@apply fill-foreground;
|
||||
@apply hover:fill-accent-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
|
||||
div :global(.maplibregl-ctrl-geocoder--icon-search) {
|
||||
@apply relative;
|
||||
@apply top-0;
|
||||
@apply left-0;
|
||||
@apply my-2;
|
||||
@apply w-[29px];
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--input) {
|
||||
div :global(.maplibregl-ctrl-geocoder--icon-loading) {
|
||||
@apply -mt-1;
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-geocoder--icon-close) {
|
||||
@apply my-0;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-geocoder--input) {
|
||||
@apply relative;
|
||||
@apply h-8;
|
||||
@apply w-64;
|
||||
@apply py-0;
|
||||
@apply pl-2;
|
||||
@@ -149,12 +153,12 @@
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
|
||||
div :global(.maplibregl-ctrl-geocoder--collapsed .maplibregl-ctrl-geocoder--input) {
|
||||
@apply w-0;
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-top-right) {
|
||||
div :global(.maplibregl-ctrl-top-right) {
|
||||
@apply z-40;
|
||||
@apply flex;
|
||||
@apply flex-col;
|
||||
@@ -163,77 +167,76 @@
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
|
||||
.horizontal :global(.maplibregl-ctrl-bottom-left) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
||||
.horizontal :global(.maplibregl-ctrl-bottom-right) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-attrib) {
|
||||
div :global(.maplibregl-ctrl-attrib) {
|
||||
@apply dark:bg-transparent;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
||||
div :global(.maplibregl-compact-show.maplibregl-ctrl-attrib) {
|
||||
@apply dark:bg-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-attrib-button) {
|
||||
div :global(.maplibregl-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
|
||||
div :global(.maplibregl-compact-show .maplibregl-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-attrib a) {
|
||||
div :global(.maplibregl-ctrl-attrib a) {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup) {
|
||||
@apply w-fit;
|
||||
div :global(.maplibregl-popup) {
|
||||
@apply z-50;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-content) {
|
||||
div :global(.maplibregl-popup-content) {
|
||||
@apply p-0;
|
||||
@apply bg-transparent;
|
||||
@apply shadow-none;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-top .maplibregl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-top-left .maplibregl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-top-right .maplibregl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-bottom .maplibregl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-left .maplibregl-popup-tip) {
|
||||
@apply border-r-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-right .maplibregl-popup-tip) {
|
||||
@apply border-l-background;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
let control: CustomControl | null = null;
|
||||
|
||||
onMount(() => {
|
||||
map.onLoad((map: mapboxgl.Map) => {
|
||||
map.onLoad((map: maplibregl.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 'mapbox-gl';
|
||||
import { type Map, type IControl } from 'maplibre-gl';
|
||||
|
||||
export default class CustomControl implements IControl {
|
||||
_map: Map | undefined;
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
</script>
|
||||
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-8 justify-start {className}"
|
||||
size="sm"
|
||||
class="justify-start {className}"
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
@@ -25,6 +26,6 @@
|
||||
onCopy();
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy size="16" class="mr-1" />
|
||||
<ClipboardCopy size="16" />
|
||||
{i18n._('menu.copy_coordinates')}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy } 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,13 +9,10 @@
|
||||
let distanceMarkers: DistanceMarkers;
|
||||
let startEndMarkers: StartEndMarkers;
|
||||
|
||||
onMount(() => {
|
||||
map.onLoad((map_) => {
|
||||
gpxLayers.init();
|
||||
startEndMarkers = new StartEndMarkers();
|
||||
distanceMarkers = new DistanceMarkers();
|
||||
});
|
||||
|
||||
map.onLoad((map_) => {
|
||||
createPopups(map_);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<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, Mountain, Timer } from '@lucide/svelte';
|
||||
import { Compass, Earth, 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>
|
||||
@@ -35,5 +37,17 @@
|
||||
onCopy={() => trackpoint.hide?.()}
|
||||
class="mt-0.5"
|
||||
/>
|
||||
{#if trackpoint.fileId === undefined}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="justify-start"
|
||||
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.getLatitude().toFixed(5)}/${trackpoint.item.getLongitude().toFixed(5)}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Earth size="14" />
|
||||
{i18n._('menu.edit_osm')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -11,12 +11,21 @@
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import type { Waypoint } from 'gpx';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import type { PopupItem } from '$lib/components/map/map';
|
||||
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';
|
||||
|
||||
export let waypoint: PopupItem<Waypoint>;
|
||||
let {
|
||||
waypoint,
|
||||
}: {
|
||||
waypoint: PopupItem<Waypoint>;
|
||||
} = $props();
|
||||
|
||||
$: symbolKey = waypoint ? getSymbolKey(waypoint.item.sym) : undefined;
|
||||
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 {
|
||||
if (text === undefined) {
|
||||
@@ -32,8 +41,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0">
|
||||
<Card.Header class="p-0 gap-0">
|
||||
<Card.Title class="text-md">
|
||||
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href}
|
||||
<a href={waypoint.item.link.attributes.href} target="_blank">
|
||||
@@ -50,11 +59,8 @@
|
||||
{#if symbolKey}
|
||||
<span>
|
||||
{#if symbols[symbolKey].icon}
|
||||
<svelte:component
|
||||
this={symbols[symbolKey].icon}
|
||||
size="12"
|
||||
class="inline-block mb-0.5"
|
||||
/>
|
||||
{@const Icon = symbols[symbolKey].icon}
|
||||
<Icon size="12" class="inline-block mb-1" />
|
||||
{:else}
|
||||
<span class="w-4 inline-block"></span>
|
||||
{/if}
|
||||
@@ -80,17 +86,18 @@
|
||||
</ScrollArea>
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
<CopyCoordinates coordinates={waypoint.item.attributes} />
|
||||
{#if $currentTool === Tool.WAYPOINT}
|
||||
{#if $currentTool === Tool.WAYPOINT && selected}
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-8 justify-start"
|
||||
class="p-1 has-[>svg]:px-2 h-8"
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
if (waypoint.fileId) {
|
||||
fileActions.deleteWaypoint(waypoint.fileId, waypoint.item._data.index);
|
||||
waypoint.hide?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
<Trash2 size="16" />
|
||||
{i18n._('menu.delete')}
|
||||
<Shortcut shift={true} click={true} />
|
||||
</Button>
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
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 '../style';
|
||||
|
||||
const { distanceMarkers, distanceUnits } = settings;
|
||||
|
||||
const stops = [
|
||||
[100, 0],
|
||||
[50, 7],
|
||||
[25, 8, 10],
|
||||
[10, 10],
|
||||
[5, 11],
|
||||
[1, 13],
|
||||
];
|
||||
const levels = [100, 50, 25, 10, 5, 1];
|
||||
|
||||
export class DistanceMarkers {
|
||||
updateBinded: () => void = this.update.bind(this);
|
||||
@@ -24,10 +19,11 @@ export class DistanceMarkers {
|
||||
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(distanceMarkers.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(distanceUnits.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(
|
||||
map.subscribe((map_) => {
|
||||
if (map_) {
|
||||
map_.on('style.import.load', this.updateBinded);
|
||||
map_.on('style.load', this.updateBinded);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -38,7 +34,7 @@ export class DistanceMarkers {
|
||||
if (!map_) return;
|
||||
|
||||
try {
|
||||
if (get(distanceMarkers)) {
|
||||
if (get(distanceMarkers) && !get(allHidden)) {
|
||||
let distanceSource: GeoJSONSource | undefined = map_.getSource('distance-markers');
|
||||
if (distanceSource) {
|
||||
distanceSource.setData(this.getDistanceMarkersGeoJSON());
|
||||
@@ -48,22 +44,33 @@ export class DistanceMarkers {
|
||||
data: this.getDistanceMarkersGeoJSON(),
|
||||
});
|
||||
}
|
||||
stops.forEach(([d, minzoom, maxzoom]) => {
|
||||
if (!map_.getLayer(`distance-markers-${d}`)) {
|
||||
map_.addLayer({
|
||||
id: `distance-markers-${d}`,
|
||||
if (!map_.getLayer('distance-markers')) {
|
||||
map_.addLayer(
|
||||
{
|
||||
id: 'distance-markers',
|
||||
type: 'symbol',
|
||||
source: 'distance-markers',
|
||||
filter:
|
||||
d === 5
|
||||
? [
|
||||
'any',
|
||||
['==', ['get', 'level'], 5],
|
||||
['==', ['get', 'level'], 25],
|
||||
]
|
||||
: ['==', ['get', 'level'], d],
|
||||
minzoom: minzoom,
|
||||
maxzoom: maxzoom ?? 24,
|
||||
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,
|
||||
],
|
||||
layout: {
|
||||
'text-field': ['get', 'distance'],
|
||||
'text-size': 14,
|
||||
@@ -74,17 +81,14 @@ export class DistanceMarkers {
|
||||
'text-halo-width': 2,
|
||||
'text-halo-color': 'white',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
map_.moveLayer(`distance-markers-${d}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
ANCHOR_LAYER_KEY.distanceMarkers
|
||||
);
|
||||
}
|
||||
} else {
|
||||
stops.forEach(([d]) => {
|
||||
if (map_.getLayer(`distance-markers-${d}`)) {
|
||||
map_.removeLayer(`distance-markers-${d}`);
|
||||
}
|
||||
});
|
||||
if (map_.getLayer('distance-markers')) {
|
||||
map_.removeLayer('distance-markers');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
@@ -99,35 +103,26 @@ export class DistanceMarkers {
|
||||
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
|
||||
let statistics = get(gpxStatistics);
|
||||
|
||||
let features = [];
|
||||
let features: GeoJSON.Feature[] = [];
|
||||
let currentTargetDistance = 1;
|
||||
for (let i = 0; i < statistics.local.distance.total.length; i++) {
|
||||
if (
|
||||
statistics.local.distance.total[i] >=
|
||||
getConvertedDistanceToKilometers(currentTargetDistance)
|
||||
) {
|
||||
statistics.forEachTrackPoint((trkpt, dist) => {
|
||||
if (dist >= getConvertedDistanceToKilometers(currentTargetDistance)) {
|
||||
let distance = currentTargetDistance.toFixed(0);
|
||||
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [
|
||||
0, 0,
|
||||
];
|
||||
let level = levels.find((level) => currentTargetDistance % level === 0) || 1;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [
|
||||
statistics.local.points[i].getLongitude(),
|
||||
statistics.local.points[i].getLatitude(),
|
||||
],
|
||||
coordinates: [trkpt.getLongitude(), trkpt.getLatitude()],
|
||||
},
|
||||
properties: {
|
||||
distance,
|
||||
level,
|
||||
minzoom,
|
||||
},
|
||||
} as GeoJSON.Feature);
|
||||
currentTargetDistance += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
|
||||
@@ -3,13 +3,14 @@ import { MapPopup } from '$lib/components/map/map-popup';
|
||||
export let waypointPopup: MapPopup | null = null;
|
||||
export let trackpointPopup: MapPopup | null = null;
|
||||
|
||||
export function createPopups(map: mapboxgl.Map) {
|
||||
export function createPopups(map: maplibregl.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,5 +1,10 @@
|
||||
import { get, type Readable } from 'svelte/store';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import maplibregl, {
|
||||
type GeoJSONSource,
|
||||
type FilterSpecification,
|
||||
type MapLayerMouseEvent,
|
||||
type MapLayerTouchEvent,
|
||||
} from 'maplibre-gl';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
|
||||
import {
|
||||
@@ -10,7 +15,7 @@ import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
} from '$lib/components/file-list/file-list';
|
||||
import { getClosestLinePoint, getElevation } from '$lib/utils';
|
||||
import { getClosestLinePoint, getElevation, loadSVGIcon } from '$lib/utils';
|
||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
||||
import { MapPin, Square } from 'lucide-static';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
@@ -22,6 +27,8 @@ import { fileActionManager } from '$lib/logic/file-action-manager';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { 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 './gpx-layers';
|
||||
|
||||
const colors = [
|
||||
'#ff0000',
|
||||
@@ -43,26 +50,49 @@ for (let color of colors) {
|
||||
}
|
||||
|
||||
// Get the color with the least amount of uses
|
||||
function getColor() {
|
||||
function getColor(fileId: string) {
|
||||
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
|
||||
colorCount[color]++;
|
||||
gpxColors.update((colors) => {
|
||||
colors.set(fileId, color);
|
||||
return colors;
|
||||
});
|
||||
return color;
|
||||
}
|
||||
|
||||
function decrementColor(color: string) {
|
||||
function replaceColor(fileId: string, oldColor: string, newColor: string) {
|
||||
if (colorCount.hasOwnProperty(oldColor)) {
|
||||
colorCount[oldColor]--;
|
||||
}
|
||||
colorCount[newColor]++;
|
||||
gpxColors.update((colors) => {
|
||||
colors.set(fileId, newColor);
|
||||
return colors;
|
||||
});
|
||||
}
|
||||
|
||||
function removeColor(fileId: string, color: string) {
|
||||
if (colorCount.hasOwnProperty(color)) {
|
||||
colorCount[color]--;
|
||||
}
|
||||
gpxColors.update((colors) => {
|
||||
colors.delete(fileId);
|
||||
return colors;
|
||||
});
|
||||
}
|
||||
|
||||
function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
|
||||
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
|
||||
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
${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}"`)}
|
||||
${
|
||||
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"', '')
|
||||
@@ -87,26 +117,41 @@ export class GPXLayer {
|
||||
fileId: string;
|
||||
file: Readable<GPXFileWithStatistics | undefined>;
|
||||
layerColor: string;
|
||||
markers: mapboxgl.Marker[] = [];
|
||||
selected: boolean = false;
|
||||
draggable: boolean;
|
||||
currentWaypointData: GeoJSON.FeatureCollection | null = null;
|
||||
draggedWaypointIndex: number | null = null;
|
||||
draggingStartingPosition: maplibregl.Point = new maplibregl.Point(0, 0);
|
||||
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: any) => void = this.layerOnClick.bind(this);
|
||||
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.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);
|
||||
|
||||
constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
|
||||
this.fileId = fileId;
|
||||
this.file = file;
|
||||
this.layerColor = getColor();
|
||||
this.layerColor = getColor(fileId);
|
||||
this.unsubscribe.push(
|
||||
map.subscribe(($map) => {
|
||||
if ($map) {
|
||||
$map.on('style.import.load', this.updateBinded);
|
||||
$map.on('style.load', this.updateBinded);
|
||||
this.update();
|
||||
}
|
||||
})
|
||||
@@ -125,24 +170,13 @@ 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 || !file) {
|
||||
if (!_map || !layerEventManager || !file) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -151,12 +185,14 @@ export class GPXLayer {
|
||||
file._data.style.color &&
|
||||
this.layerColor !== `#${file._data.style.color}`
|
||||
) {
|
||||
decrementColor(this.layerColor);
|
||||
replaceColor(this.fileId, this.layerColor, `#${file._data.style.color}`);
|
||||
this.layerColor = `#${file._data.style.color}`;
|
||||
}
|
||||
|
||||
this.loadIcons();
|
||||
|
||||
try {
|
||||
let source = _map.getSource(this.fileId);
|
||||
let source = _map.getSource(this.fileId) as GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
source.setData(this.getGeoJSON());
|
||||
} else {
|
||||
@@ -167,28 +203,45 @@ 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',
|
||||
_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'],
|
||||
},
|
||||
},
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': ['get', 'width'],
|
||||
'line-opacity': ['get', 'opacity'],
|
||||
},
|
||||
});
|
||||
ANCHOR_LAYER_KEY.tracks
|
||||
);
|
||||
|
||||
_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);
|
||||
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);
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -213,172 +266,136 @@ export class GPXLayer {
|
||||
'text-halo-color': 'white',
|
||||
},
|
||||
},
|
||||
_map.getLayer('distance-markers') ? 'distance-markers' : undefined
|
||||
ANCHOR_LAYER_KEY.directionMarkers
|
||||
);
|
||||
}
|
||||
|
||||
_map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false });
|
||||
} else {
|
||||
if (_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.removeLayer(this.fileId + '-direction');
|
||||
}
|
||||
}
|
||||
|
||||
let visibleItems: [number, number][] = [];
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (!segment._data.hidden) {
|
||||
visibleItems.push([trackIndex, segmentIndex]);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
_map.setFilter(
|
||||
this.fileId,
|
||||
[
|
||||
'any',
|
||||
...visibleItems.map(([trackIndex, segmentIndex]) => [
|
||||
'all',
|
||||
['==', 'trackIndex', trackIndex],
|
||||
['==', 'segmentIndex', segmentIndex],
|
||||
]),
|
||||
],
|
||||
this.fileId + '-waypoints',
|
||||
['in', ['get', 'waypointIndex'], ['literal', visibleWaypoints]],
|
||||
{ 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('click', this.fileId, this.layerOnClickBinded);
|
||||
_map.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||
_map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||
_map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||
_map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
_map.off('style.import.load', this.updateBinded);
|
||||
|
||||
if (_map) {
|
||||
_map.off('style.load', this.updateBinded);
|
||||
}
|
||||
|
||||
const layerEventManager = map.layerEventManager;
|
||||
if (layerEventManager) {
|
||||
layerEventManager.off('click', this.fileId, this.layerOnClickBinded);
|
||||
layerEventManager.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||
layerEventManager.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||
layerEventManager.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||
layerEventManager.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
|
||||
layerEventManager.off(
|
||||
'mouseenter',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseEnterBinded
|
||||
);
|
||||
layerEventManager.off(
|
||||
'mouseleave',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseLeaveBinded
|
||||
);
|
||||
layerEventManager.off(
|
||||
'click',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnClickBinded
|
||||
);
|
||||
layerEventManager.off(
|
||||
'mousedown',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseDownBinded
|
||||
);
|
||||
layerEventManager.off(
|
||||
'touchstart',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnTouchStartBinded
|
||||
);
|
||||
}
|
||||
|
||||
if (_map) {
|
||||
if (_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.removeLayer(this.fileId + '-direction');
|
||||
}
|
||||
@@ -388,15 +405,17 @@ 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());
|
||||
|
||||
decrementColor(this.layerColor);
|
||||
removeColor(this.fileId, this.layerColor);
|
||||
}
|
||||
|
||||
moveToFront() {
|
||||
@@ -405,13 +424,13 @@ export class GPXLayer {
|
||||
return;
|
||||
}
|
||||
if (_map.getLayer(this.fileId)) {
|
||||
_map.moveLayer(this.fileId);
|
||||
_map.moveLayer(this.fileId, ANCHOR_LAYER_KEY.tracks);
|
||||
}
|
||||
if (_map.getLayer(this.fileId + '-waypoints')) {
|
||||
_map.moveLayer(this.fileId + '-waypoints', ANCHOR_LAYER_KEY.waypoints);
|
||||
}
|
||||
if (_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.moveLayer(
|
||||
this.fileId + '-direction',
|
||||
_map.getLayer('distance-markers') ? 'distance-markers' : undefined
|
||||
);
|
||||
_map.moveLayer(this.fileId + '-direction', ANCHOR_LAYER_KEY.directionMarkers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +471,7 @@ export class GPXLayer {
|
||||
}
|
||||
}
|
||||
|
||||
layerOnClick(e: any) {
|
||||
layerOnClick(e: MapLayerMouseEvent) {
|
||||
if (
|
||||
get(currentTool) === Tool.ROUTING &&
|
||||
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
|
||||
@@ -460,8 +479,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 &&
|
||||
@@ -469,6 +488,11 @@ 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,
|
||||
@@ -505,6 +529,179 @@ export class GPXLayer {
|
||||
}
|
||||
}
|
||||
|
||||
waypointLayerOnMouseEnter(e: MapLayerMouseEvent) {
|
||||
if (this.draggedWaypointIndex !== null) {
|
||||
return;
|
||||
}
|
||||
let file = get(this.file)?.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
let waypointIndex = e.features![0].properties!.waypointIndex;
|
||||
let waypoint = file.wpt[waypointIndex];
|
||||
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
|
||||
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true);
|
||||
}
|
||||
|
||||
waypointLayerOnMouseLeave() {
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false);
|
||||
}
|
||||
|
||||
waypointLayerOnClick(e: MapLayerMouseEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
let waypointIndex = e.features![0].properties!.waypointIndex;
|
||||
let file = get(this.file)?.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
let waypoint = file.wpt[waypointIndex];
|
||||
if (get(currentTool) === Tool.WAYPOINT) {
|
||||
if (this.selected) {
|
||||
if (e.originalEvent.shiftKey) {
|
||||
fileActions.deleteWaypoint(this.fileId, waypointIndex);
|
||||
} else {
|
||||
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||
selectedWaypoint.set([waypoint, this.fileId]);
|
||||
}
|
||||
} else {
|
||||
if (get(treeFileView)) {
|
||||
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||
} else {
|
||||
selection.selectItem(new ListFileItem(this.fileId));
|
||||
}
|
||||
selectedWaypoint.set([waypoint, this.fileId]);
|
||||
}
|
||||
} else {
|
||||
if (get(treeFileView)) {
|
||||
if ((e.originalEvent.ctrlKey || e.originalEvent.metaKey) && this.selected) {
|
||||
selection.addSelectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||
} else {
|
||||
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||
}
|
||||
} else {
|
||||
if (!this.selected) {
|
||||
selection.selectItem(new ListFileItem(this.fileId));
|
||||
}
|
||||
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
waypointLayerOnMouseDown(e: MapLayerMouseEvent) {
|
||||
if (get(currentTool) !== Tool.WAYPOINT || !this.selected) {
|
||||
return;
|
||||
}
|
||||
const _map = get(map);
|
||||
if (!_map) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
_map.dragPan.disable();
|
||||
|
||||
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
|
||||
this.draggingStartingPosition = e.point;
|
||||
waypointPopup?.hide();
|
||||
|
||||
_map.on('mousemove', this.waypointLayerOnMouseMoveBinded);
|
||||
_map.once('mouseup', this.waypointLayerOnMouseUpBinded);
|
||||
}
|
||||
|
||||
waypointLayerOnTouchStart(e: MapLayerTouchEvent) {
|
||||
if (e.points.length !== 1 || get(currentTool) !== Tool.WAYPOINT || !this.selected) {
|
||||
return;
|
||||
}
|
||||
const _map = get(map);
|
||||
if (!_map) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
|
||||
this.draggingStartingPosition = e.point;
|
||||
waypointPopup?.hide();
|
||||
|
||||
e.preventDefault();
|
||||
_map.dragPan.disable();
|
||||
|
||||
_map.on('touchmove', this.waypointLayerOnMouseMoveBinded);
|
||||
_map.once('touchend', this.waypointLayerOnMouseUpBinded);
|
||||
}
|
||||
|
||||
waypointLayerOnMouseMove(e: MapLayerMouseEvent | MapLayerTouchEvent) {
|
||||
if (this.draggedWaypointIndex === null || e.point.equals(this.draggingStartingPosition)) {
|
||||
return;
|
||||
}
|
||||
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, true);
|
||||
|
||||
(
|
||||
this.currentWaypointData!.features[this.draggedWaypointIndex].geometry as GeoJSON.Point
|
||||
).coordinates = [e.lngLat.lng, e.lngLat.lat];
|
||||
|
||||
let waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as
|
||||
| GeoJSONSource
|
||||
| undefined;
|
||||
if (waypointSource) {
|
||||
waypointSource.updateData({
|
||||
update: [
|
||||
{
|
||||
id: this.draggedWaypointIndex,
|
||||
newGeometry: {
|
||||
type: 'Point',
|
||||
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
waypointLayerOnMouseUp(e: MapLayerMouseEvent | MapLayerTouchEvent) {
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
|
||||
|
||||
const _map = get(map);
|
||||
if (!_map) {
|
||||
return;
|
||||
}
|
||||
|
||||
_map.dragPan.enable();
|
||||
|
||||
_map.off('mousemove', this.waypointLayerOnMouseMoveBinded);
|
||||
_map.off('touchmove', this.waypointLayerOnMouseMoveBinded);
|
||||
|
||||
if (this.draggedWaypointIndex === null) {
|
||||
return;
|
||||
}
|
||||
if (e.point.equals(this.draggingStartingPosition)) {
|
||||
this.draggedWaypointIndex = null;
|
||||
return;
|
||||
}
|
||||
|
||||
getElevation([
|
||||
{
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
},
|
||||
]).then((ele) => {
|
||||
if (this.draggedWaypointIndex === null) {
|
||||
return;
|
||||
}
|
||||
fileActionManager.applyToFile(this.fileId, (file) => {
|
||||
let wpt = file.wpt[this.draggedWaypointIndex!];
|
||||
wpt.setCoordinates({
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
});
|
||||
wpt.ele = ele[0];
|
||||
});
|
||||
this.draggedWaypointIndex = null;
|
||||
});
|
||||
}
|
||||
|
||||
getGeoJSON(): GeoJSON.FeatureCollection {
|
||||
let file = get(this.file)?.file;
|
||||
if (!file) {
|
||||
@@ -542,6 +739,7 @@ export class GPXLayer {
|
||||
}
|
||||
feature.properties.trackIndex = trackIndex;
|
||||
feature.properties.segmentIndex = segmentIndex;
|
||||
feature.properties.trackSegmentId = `${trackIndex}-${segmentIndex}`;
|
||||
|
||||
segmentIndex++;
|
||||
if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
|
||||
@@ -551,4 +749,52 @@ 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,4 +1,5 @@
|
||||
import { GPXFileStateCollectionObserver } from '$lib/logic/file-state';
|
||||
import { writable } from 'svelte/store';
|
||||
import { GPXLayer } from './gpx-layer';
|
||||
|
||||
export class GPXLayerCollection {
|
||||
@@ -35,6 +36,11 @@ export class GPXLayerCollection {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getLayer(fileId: string): GPXLayer | undefined {
|
||||
return this._layers.get(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
export const gpxLayers = new GPXLayerCollection();
|
||||
export const gpxColors = writable(new Map<string, string>());
|
||||
|
||||
@@ -1,54 +1,157 @@
|
||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||
import type { GeoJSONSource } from 'maplibre-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() {
|
||||
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));
|
||||
}
|
||||
|
||||
update() {
|
||||
const map_ = get(map);
|
||||
if (!map_) return;
|
||||
|
||||
this.loadIcons();
|
||||
|
||||
const tool = get(currentTool);
|
||||
const statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
|
||||
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING) {
|
||||
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(map_);
|
||||
this.end
|
||||
.setLngLat(
|
||||
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
|
||||
)
|
||||
.addTo(map_);
|
||||
const statistics = get(gpxStatistics);
|
||||
const slicedStatistics = get(slicedGPXStatistics);
|
||||
const hovered = get(hoveredPoint);
|
||||
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
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.start.remove();
|
||||
this.end.remove();
|
||||
if (map_.getLayer('start-end-markers')) {
|
||||
map_.removeLayer('start-end-markers');
|
||||
}
|
||||
if (map_.getSource('start-end-markers')) {
|
||||
map_.removeSource('start-end-markers');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
this.start.remove();
|
||||
this.end.remove();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,10 @@
|
||||
} from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Sortable from 'sortablejs/Sortable';
|
||||
import { customBasemapUpdate } from './utils';
|
||||
import { onMount } from 'svelte';
|
||||
import { remove } from './utils';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
|
||||
const {
|
||||
customLayers,
|
||||
@@ -42,25 +41,14 @@
|
||||
let maxZoom: number = $state(20);
|
||||
let layerType: 'basemap' | 'overlay' = $state('basemap');
|
||||
let resourceType: 'raster' | 'vector' = $derived.by(() => {
|
||||
if (tileUrls[0].length > 0) {
|
||||
if (
|
||||
tileUrls[0].includes('.json') ||
|
||||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
|
||||
) {
|
||||
return 'vector';
|
||||
}
|
||||
if (tileUrls[0].length > 0 && tileUrls[0].includes('.json')) {
|
||||
return 'vector';
|
||||
}
|
||||
return 'raster';
|
||||
});
|
||||
|
||||
let selectedLayerId: string | undefined = $state(undefined);
|
||||
|
||||
let basemapContainer: HTMLElement;
|
||||
let overlayContainer: HTMLElement;
|
||||
|
||||
let basemapSortable: Sortable;
|
||||
let overlaySortable: Sortable;
|
||||
|
||||
onMount(() => {
|
||||
if ($customBasemapOrder.length === 0) {
|
||||
$customBasemapOrder = Object.keys($customLayers).filter(
|
||||
@@ -72,34 +60,26 @@
|
||||
(id) => $customLayers[id].layerType === 'overlay'
|
||||
);
|
||||
}
|
||||
|
||||
basemapSortable = Sortable.create(basemapContainer, {
|
||||
onSort: (e) => {
|
||||
$customBasemapOrder = basemapSortable.toArray();
|
||||
$selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => {
|
||||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
},
|
||||
});
|
||||
overlaySortable = Sortable.create(overlayContainer, {
|
||||
onSort: (e) => {
|
||||
$customOverlayOrder = overlaySortable.toArray();
|
||||
$selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => {
|
||||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
},
|
||||
});
|
||||
|
||||
basemapSortable.sort($customBasemapOrder);
|
||||
overlaySortable.sort($customOverlayOrder);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
basemapSortable.destroy();
|
||||
overlaySortable.destroy();
|
||||
});
|
||||
let customBasemapItems: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[] = $derived(
|
||||
$customBasemapOrder.map((id) => ({
|
||||
id: id,
|
||||
name: $customLayers[id].name,
|
||||
}))
|
||||
);
|
||||
let customOverlayItems: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[] = $derived(
|
||||
$customOverlayOrder.map((id) => ({
|
||||
id: id,
|
||||
name: $customLayers[id].name,
|
||||
}))
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
setDataFromSelectedLayer(selectedLayerId);
|
||||
@@ -148,8 +128,8 @@
|
||||
],
|
||||
};
|
||||
}
|
||||
$customLayers[layerId] = layer;
|
||||
addLayer(layerId);
|
||||
$customLayers[layerId] = layer;
|
||||
selectedLayerId = undefined;
|
||||
setDataFromSelectedLayer();
|
||||
}
|
||||
@@ -172,9 +152,7 @@
|
||||
return $tree;
|
||||
});
|
||||
|
||||
if ($currentBasemap === layerId) {
|
||||
$customBasemapUpdate++;
|
||||
} else {
|
||||
if ($currentBasemap !== layerId) {
|
||||
$currentBasemap = layerId;
|
||||
}
|
||||
|
||||
@@ -190,22 +168,13 @@
|
||||
return $tree;
|
||||
});
|
||||
|
||||
if (
|
||||
$currentOverlays.overlays['custom'] &&
|
||||
$currentOverlays.overlays['custom'][layerId] &&
|
||||
$map
|
||||
) {
|
||||
try {
|
||||
$map.removeImport(layerId);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
currentOverlays.update(($overlays) => {
|
||||
if (!$overlays.overlays.hasOwnProperty('custom')) {
|
||||
$overlays.overlays['custom'] = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
|
||||
$currentOverlays.overlays['custom'] = {};
|
||||
}
|
||||
$currentOverlays.overlays['custom'][layerId] = true;
|
||||
$overlays.overlays['custom'][layerId] = true;
|
||||
return $overlays;
|
||||
});
|
||||
|
||||
if (!$customOverlayOrder.includes(layerId)) {
|
||||
$customOverlayOrder = [...$customOverlayOrder, layerId];
|
||||
@@ -230,49 +199,15 @@
|
||||
$previousBasemap = defaultBasemap;
|
||||
}
|
||||
|
||||
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
|
||||
$selectedBasemapTree.basemaps['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
|
||||
$selectedBasemapTree.basemaps = tryDeleteLayer(
|
||||
$selectedBasemapTree.basemaps,
|
||||
'custom'
|
||||
);
|
||||
}
|
||||
$selectedBasemapTree = remove($selectedBasemapTree, layerId);
|
||||
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
|
||||
} else {
|
||||
$currentOverlays.overlays['custom'][layerId] = false;
|
||||
if ($previousOverlays.overlays['custom']) {
|
||||
$previousOverlays.overlays['custom'] = tryDeleteLayer(
|
||||
$previousOverlays.overlays['custom'],
|
||||
layerId
|
||||
);
|
||||
}
|
||||
|
||||
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
|
||||
$selectedOverlayTree.overlays['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
|
||||
$selectedOverlayTree.overlays = tryDeleteLayer(
|
||||
$selectedOverlayTree.overlays,
|
||||
'custom'
|
||||
);
|
||||
if ($currentOverlays) {
|
||||
$currentOverlays = remove($currentOverlays, layerId);
|
||||
}
|
||||
$previousOverlays = remove($previousOverlays, layerId);
|
||||
$selectedOverlayTree = remove($selectedOverlayTree, layerId);
|
||||
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
|
||||
|
||||
if (
|
||||
$currentOverlays.overlays['custom'] &&
|
||||
$currentOverlays.overlays['custom'][layerId] &&
|
||||
$map
|
||||
) {
|
||||
try {
|
||||
$map.removeImport(layerId);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
}
|
||||
$customLayers = tryDeleteLayer($customLayers, layerId);
|
||||
}
|
||||
@@ -306,17 +241,37 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={basemapContainer}
|
||||
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
|
||||
use:dndzone={{
|
||||
items: customBasemapItems,
|
||||
type: 'basemap',
|
||||
dropTargetStyle: {},
|
||||
transformDraggedElement: (element) => {
|
||||
if (element) {
|
||||
element.style.opacity = '0.5';
|
||||
}
|
||||
},
|
||||
}}
|
||||
onconsider={(e) => {
|
||||
customBasemapItems = e.detail.items;
|
||||
}}
|
||||
onfinalize={(e) => {
|
||||
customBasemapItems = e.detail.items;
|
||||
$customBasemapOrder = customBasemapItems.map((item) => item.id);
|
||||
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce((acc, item) => {
|
||||
acc[item.id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}}
|
||||
>
|
||||
{#each $customBasemapOrder as id (id)}
|
||||
<div class="flex flex-row items-center gap-2" data-id={id}>
|
||||
{#each customBasemapItems as item (item.id)}
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Move size="12" />
|
||||
<span class="grow">{$customLayers[id].name}</span>
|
||||
<span class="grow">{item.name}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onclick={() => (selectedLayerId = id)}
|
||||
onclick={() => (selectedLayerId = item.id)}
|
||||
class="p-1 h-7"
|
||||
>
|
||||
<Pencil size="16" />
|
||||
@@ -324,7 +279,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onclick={() => deleteLayer(id)}
|
||||
onclick={() => deleteLayer(item.id)}
|
||||
class="p-1 h-7"
|
||||
>
|
||||
<Trash2 size="16" />
|
||||
@@ -342,17 +297,37 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={overlayContainer}
|
||||
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
|
||||
use:dndzone={{
|
||||
items: customOverlayItems,
|
||||
type: 'overlay',
|
||||
dropTargetStyle: {},
|
||||
transformDraggedElement: (element) => {
|
||||
if (element) {
|
||||
element.style.opacity = '0.5';
|
||||
}
|
||||
},
|
||||
}}
|
||||
onconsider={(e) => {
|
||||
customOverlayItems = e.detail.items;
|
||||
}}
|
||||
onfinalize={(e) => {
|
||||
customOverlayItems = e.detail.items;
|
||||
$customOverlayOrder = customOverlayItems.map((item) => item.id);
|
||||
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce((acc, item) => {
|
||||
acc[item.id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}}
|
||||
>
|
||||
{#each $customOverlayOrder as id (id)}
|
||||
<div class="flex flex-row items-center gap-2" data-id={id}>
|
||||
{#each customOverlayItems as item (item.id)}
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Move size="12" />
|
||||
<span class="grow">{$customLayers[id].name}</span>
|
||||
<span class="grow">{item.name}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onclick={() => (selectedLayerId = id)}
|
||||
onclick={() => (selectedLayerId = item.id)}
|
||||
class="p-1 h-7"
|
||||
>
|
||||
<Pencil size="16" />
|
||||
@@ -360,7 +335,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onclick={() => deleteLayer(id)}
|
||||
onclick={() => deleteLayer(item.id)}
|
||||
class="p-1 h-7"
|
||||
>
|
||||
<Trash2 size="16" />
|
||||
@@ -437,7 +412,7 @@
|
||||
{#if selectedLayerId}
|
||||
<div class="mt-2 flex flex-row gap-2">
|
||||
<Button variant="outline" onclick={createLayer} class="grow">
|
||||
<Save size="16" class="mr-1" />
|
||||
<Save size="16" />
|
||||
{i18n._('layers.custom_layers.update')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={() => (selectedLayerId = undefined)}>
|
||||
@@ -446,7 +421,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<Button variant="outline" class="mt-2" onclick={createLayer}>
|
||||
<CirclePlus size="16" class="mr-1" />
|
||||
<CirclePlus size="16" />
|
||||
{i18n._('layers.custom_layers.create')}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
@@ -5,12 +5,8 @@
|
||||
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;
|
||||
@@ -23,127 +19,14 @@
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
customLayers,
|
||||
opacities,
|
||||
} = settings;
|
||||
|
||||
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) => {
|
||||
map.onLoad((_map: maplibregl.Map) => {
|
||||
if (overpassLayer) {
|
||||
overpassLayer.remove();
|
||||
}
|
||||
overpassLayer = new OverpassLayer(_map);
|
||||
overpassLayer = new OverpassLayer(_map, map.layerEventManager!);
|
||||
overpassLayer.add();
|
||||
let first = true;
|
||||
_map.on('style.import.load', () => {
|
||||
if (!first) return;
|
||||
first = false;
|
||||
updateOverlays();
|
||||
});
|
||||
});
|
||||
|
||||
let open = $state(false);
|
||||
@@ -227,8 +110,9 @@
|
||||
</CustomControl>
|
||||
|
||||
<svelte:window
|
||||
on:click={(e) => {
|
||||
if (open && !cancelEvents && !container.contains(e.target)) {
|
||||
on:click={(e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (open && !cancelEvents && target && container && !container.contains(target)) {
|
||||
closeLayerControl();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -13,12 +13,15 @@
|
||||
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';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import CustomLayers from './CustomLayers.svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { untrack } from 'svelte';
|
||||
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
|
||||
|
||||
const {
|
||||
selectedBasemapTree,
|
||||
@@ -26,10 +29,14 @@
|
||||
selectedOverpassTree,
|
||||
currentBasemap,
|
||||
currentOverlays,
|
||||
currentOverpassQueries,
|
||||
customLayers,
|
||||
opacities,
|
||||
terrainSource,
|
||||
} = settings;
|
||||
|
||||
const { isLayerFromExtension, getLayerName } = extensionAPI;
|
||||
|
||||
let { open = $bindable() }: { open: boolean } = $props();
|
||||
|
||||
let accordionValue: string | undefined = $state(undefined);
|
||||
@@ -49,7 +56,7 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($selectedBasemapTree && $currentBasemap) {
|
||||
if (open && $selectedBasemapTree && $currentBasemap) {
|
||||
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
|
||||
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
|
||||
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
|
||||
@@ -60,19 +67,44 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($selectedOverlayTree && $currentOverlays) {
|
||||
let overlayLayers = getLayers($currentOverlays);
|
||||
let toRemove = Object.entries(overlayLayers).filter(
|
||||
([id, checked]) => checked && !isSelected($selectedOverlayTree, id)
|
||||
);
|
||||
if (toRemove.length > 0) {
|
||||
currentOverlays.update((tree) => {
|
||||
toRemove.forEach(([id]) => {
|
||||
toggle(tree, id);
|
||||
});
|
||||
return tree;
|
||||
});
|
||||
}
|
||||
if (open && $selectedOverlayTree) {
|
||||
untrack(() => {
|
||||
if ($currentOverlays) {
|
||||
let overlayLayers = getLayers($currentOverlays);
|
||||
let toRemove = Object.entries(overlayLayers).filter(
|
||||
([id, checked]) => checked && !isSelected($selectedOverlayTree, id)
|
||||
);
|
||||
if (toRemove.length > 0) {
|
||||
currentOverlays.update((tree) => {
|
||||
toRemove.forEach(([id]) => {
|
||||
toggle(tree, id);
|
||||
});
|
||||
return tree;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open && $selectedOverpassTree) {
|
||||
untrack(() => {
|
||||
if ($currentOverpassQueries) {
|
||||
let overlayLayers = getLayers($currentOverpassQueries);
|
||||
let toRemove = Object.entries(overlayLayers).filter(
|
||||
([id, checked]) => checked && !isSelected($selectedOverpassTree, id)
|
||||
);
|
||||
if (toRemove.length > 0) {
|
||||
currentOverpassQueries.update((tree) => {
|
||||
toRemove.forEach(([id]) => {
|
||||
toggle(tree, id);
|
||||
});
|
||||
return tree;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -130,21 +162,29 @@
|
||||
type="single"
|
||||
onValueChange={setOpacityFromSelection}
|
||||
>
|
||||
<Select.Trigger class="h-8 mr-1 w-full">
|
||||
<Select.Trigger class="mr-1 w-full" size="sm">
|
||||
{#if selectedOverlay}
|
||||
{#if isSelected($selectedOverlayTree, selectedOverlay)}
|
||||
{i18n._(`layers.label.${selectedOverlay}`)}
|
||||
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
|
||||
{$customLayers[selectedOverlay].name}
|
||||
{#if $isLayerFromExtension(selectedOverlay)}
|
||||
{$getLayerName(selectedOverlay)}
|
||||
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
|
||||
{$customLayers[selectedOverlay].name}
|
||||
{:else}
|
||||
{i18n._(`layers.label.${selectedOverlay}`)}
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
|
||||
{#each Object.keys(overlays) as id}
|
||||
{#if isSelected($selectedOverlayTree, id)}
|
||||
<Select.Item value={id}
|
||||
>{i18n._(`layers.label.${id}`)}</Select.Item
|
||||
>
|
||||
<Select.Item value={id}>
|
||||
{#if $isLayerFromExtension(id)}
|
||||
{$getLayerName(id)}
|
||||
{:else}
|
||||
{i18n._(`layers.label.${id}`)}
|
||||
{/if}
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each Object.entries($customLayers) as [id, layer]}
|
||||
@@ -173,7 +213,9 @@
|
||||
isSelected($currentOverlays, selectedOverlay)
|
||||
) {
|
||||
try {
|
||||
$map.removeImport(selectedOverlay);
|
||||
if ($map.getLayer(selectedOverlay)) {
|
||||
$map.removeLayer(selectedOverlay);
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
@@ -195,6 +237,23 @@
|
||||
</ScrollArea>
|
||||
</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>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</ScrollArea>
|
||||
</Sheet.Header>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { anySelectedLayer } from './utils';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
|
||||
|
||||
let {
|
||||
name,
|
||||
@@ -25,6 +26,7 @@
|
||||
} = $props();
|
||||
|
||||
const { customLayers } = settings;
|
||||
const { isLayerFromExtension, getLayerName } = extensionAPI;
|
||||
|
||||
$effect.pre(() => {
|
||||
if (checked !== undefined) {
|
||||
@@ -72,6 +74,8 @@
|
||||
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
||||
{#if $customLayers.hasOwnProperty(id)}
|
||||
{$customLayers[id].name}
|
||||
{:else if $isLayerFromExtension(id)}
|
||||
{$getLayerName(id)}
|
||||
{:else}
|
||||
{i18n._(`layers.label.${id}`)}
|
||||
{/if}
|
||||
@@ -81,7 +85,7 @@
|
||||
{:else if anySelectedLayer(node[id])}
|
||||
<CollapsibleTreeNode {id}>
|
||||
{#snippet trigger()}
|
||||
<span>{i18n._(`layers.label.${id}`)}</span>
|
||||
<span>{i18n._(`layers.label.${id}`, id)}</span>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="ml-2">
|
||||
|
||||
@@ -5,22 +5,27 @@
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import type { WaypointType } from 'gpx';
|
||||
import type { PopupItem } from '$lib/components/map/map';
|
||||
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
|
||||
export let poi: PopupItem<any>;
|
||||
let {
|
||||
poi,
|
||||
}: {
|
||||
poi: PopupItem<any>;
|
||||
} = $props();
|
||||
|
||||
let tags: { [key: string]: string } = {};
|
||||
let name = '';
|
||||
$: if (poi) {
|
||||
tags = JSON.parse(poi.item.tags);
|
||||
if (tags.name !== undefined && tags.name !== '') {
|
||||
name = tags.name;
|
||||
} else {
|
||||
name = i18n._(`layers.label.${poi.item.query}`);
|
||||
let tags: Record<string, string> = $derived(poi ? JSON.parse(poi.item.tags) : {});
|
||||
let name = $derived.by(() => {
|
||||
if (poi) {
|
||||
if (tags.name !== undefined && tags.name !== '') {
|
||||
return tags.name;
|
||||
} else {
|
||||
return i18n._(`layers.label.${poi.item.query}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
function addToFile() {
|
||||
const desc = Object.entries(tags)
|
||||
@@ -47,33 +52,33 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md">
|
||||
<div class="flex flex-row gap-3">
|
||||
<div class="flex flex-col">
|
||||
{name}
|
||||
<div class="text-muted-foreground text-sm font-normal">
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||
</div>
|
||||
<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)}° {poi.item.lon.toFixed(6)}°
|
||||
</div>
|
||||
<Button
|
||||
class="ml-auto p-1.5 h-8"
|
||||
variant="outline"
|
||||
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 p-0 text-sm mt-1 whitespace-normal break-all">
|
||||
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
|
||||
<Card.Content class="flex flex-col gap-1 p-0 text-sm 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">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<!-- svelte-ignore a11y_missing_attribute -->
|
||||
<img src={tags.image ?? tags['image:0']} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -81,7 +86,7 @@
|
||||
{#each Object.entries(tags) as [key, value]}
|
||||
{#if key !== 'name' && !key.includes('image')}
|
||||
<span class="font-mono">{key}</span>
|
||||
{#if key === 'website' || key.startsWith('website:') || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
||||
{#if key === 'website' || key.startsWith('website:') || key.endsWith(':website') || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
||||
<a href={value} target="_blank" class="text-link underline">{value}</a>
|
||||
{:else if key === 'phone' || key === 'contact:phone'}
|
||||
<a href={'tel:' + value} class="text-link underline">{value}</a>
|
||||
@@ -94,8 +99,14 @@
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Button class="mt-2" variant="outline" disabled={$selection.size === 0} onclick={addToFile}>
|
||||
<MapPin size="16" class="mr-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
class="mt-1 justify-start"
|
||||
variant="outline"
|
||||
disabled={$selection.size === 0}
|
||||
onclick={addToFile}
|
||||
>
|
||||
<MapPin size="14" />
|
||||
{i18n._('toolbar.waypoint.add')}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
|
||||
213
website/src/lib/components/map/layer-control/extension-api.ts
Normal file
213
website/src/lib/components/map/layer-control/extension-api.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { derived, get, writable, type Writable } from 'svelte/store';
|
||||
import { isSelected, remove, removeAll } from './utils';
|
||||
import { overlays, overlayTree } from '$lib/assets/layers';
|
||||
import { browser } from '$app/environment';
|
||||
import { map } from '$lib/components/map/map';
|
||||
|
||||
const { currentOverlays, previousOverlays, selectedOverlayTree } = settings;
|
||||
|
||||
export type CustomOverlay = {
|
||||
extensionName: string;
|
||||
id: string;
|
||||
name: string;
|
||||
tileUrls: string[];
|
||||
maxZoom?: number;
|
||||
};
|
||||
|
||||
export class ExtensionAPI {
|
||||
private _overlays: Writable<Map<string, CustomOverlay>> = writable(new Map());
|
||||
|
||||
init() {
|
||||
if (browser && !window.hasOwnProperty('gpxstudio')) {
|
||||
Object.defineProperty(window, 'gpxstudio', {
|
||||
value: this,
|
||||
});
|
||||
addEventListener('beforeunload', () => {
|
||||
this.destroy();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ensureLoaded(): Promise<void> {
|
||||
let unsubscribe: () => void;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
map.onLoad(() => {
|
||||
unsubscribe = currentOverlays.subscribe((current) => {
|
||||
if (current) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
promise.finally(() => {
|
||||
unsubscribe?.();
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
addOrUpdateOverlay(overlay: CustomOverlay) {
|
||||
if (
|
||||
!overlay.extensionName ||
|
||||
!overlay.id ||
|
||||
!overlay.name ||
|
||||
!overlay.tileUrls ||
|
||||
overlay.tileUrls.length === 0
|
||||
) {
|
||||
throw new Error(
|
||||
'Overlay must have an extensionName, id, name, and at least one tile URL.'
|
||||
);
|
||||
}
|
||||
overlay.id = this.getOverlayId(overlay.id);
|
||||
|
||||
this._overlays.update(($overlays) => {
|
||||
$overlays.set(overlay.id, overlay);
|
||||
return $overlays;
|
||||
});
|
||||
|
||||
overlays[overlay.id] = {
|
||||
version: 8,
|
||||
sources: {
|
||||
[overlay.id]: {
|
||||
type: 'raster',
|
||||
tiles: overlay.tileUrls,
|
||||
tileSize: overlay.tileUrls.some((url) => url.includes('512')) ? 512 : 256,
|
||||
maxzoom: overlay.maxZoom ?? 22,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: overlay.id,
|
||||
type: 'raster',
|
||||
source: overlay.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (!overlayTree.overlays.hasOwnProperty(overlay.extensionName)) {
|
||||
overlayTree.overlays[overlay.extensionName] = {};
|
||||
}
|
||||
|
||||
overlayTree.overlays[overlay.extensionName][overlay.id] = true;
|
||||
|
||||
selectedOverlayTree.update((selected) => {
|
||||
if (!selected.overlays.hasOwnProperty(overlay.extensionName)) {
|
||||
selected.overlays[overlay.extensionName] = {};
|
||||
}
|
||||
selected.overlays[overlay.extensionName][overlay.id] = true;
|
||||
return selected;
|
||||
});
|
||||
|
||||
const current = get(currentOverlays);
|
||||
let show = false;
|
||||
if (current && isSelected(current, overlay.id)) {
|
||||
show = true;
|
||||
try {
|
||||
get(map)?.removeLayer(overlay.id);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
currentOverlays.update((current) => {
|
||||
if (!current.overlays.hasOwnProperty(overlay.extensionName)) {
|
||||
current.overlays[overlay.extensionName] = {};
|
||||
}
|
||||
current.overlays[overlay.extensionName][overlay.id] = show;
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
filterOverlays(ids: string[]) {
|
||||
ids = ids.map((id) => this.getOverlayId(id));
|
||||
const idsToRemove = Array.from(get(this._overlays).keys()).filter(
|
||||
(id) => !ids.includes(id)
|
||||
);
|
||||
|
||||
currentOverlays.update((current) => {
|
||||
removeAll(current, idsToRemove);
|
||||
return current;
|
||||
});
|
||||
previousOverlays.update((previous) => {
|
||||
removeAll(previous, idsToRemove);
|
||||
return previous;
|
||||
});
|
||||
selectedOverlayTree.update((selected) => {
|
||||
removeAll(selected, idsToRemove);
|
||||
return selected;
|
||||
});
|
||||
Object.keys(overlays).forEach((id) => {
|
||||
if (idsToRemove.includes(id)) {
|
||||
delete overlays[id];
|
||||
}
|
||||
});
|
||||
removeAll(overlayTree, idsToRemove);
|
||||
this._overlays.update(($overlays) => {
|
||||
$overlays.forEach((_, id) => {
|
||||
if (idsToRemove.includes(id)) {
|
||||
$overlays.delete(id);
|
||||
}
|
||||
});
|
||||
return $overlays;
|
||||
});
|
||||
}
|
||||
|
||||
updateOverlaysOrder(ids: string[]) {
|
||||
ids = ids.map((id) => this.getOverlayId(id));
|
||||
selectedOverlayTree.update((selected) => {
|
||||
let isSelected: Record<string, boolean> = {};
|
||||
ids.forEach((id) => {
|
||||
const overlay = get(this._overlays).get(id);
|
||||
if (
|
||||
overlay &&
|
||||
selected.overlays.hasOwnProperty(overlay.extensionName) &&
|
||||
selected.overlays[overlay.extensionName].hasOwnProperty(id)
|
||||
) {
|
||||
isSelected[id] = selected.overlays[overlay.extensionName][id];
|
||||
delete selected.overlays[overlay.extensionName][id];
|
||||
}
|
||||
});
|
||||
Object.entries(isSelected).forEach(([id, value]) => {
|
||||
const overlay = get(this._overlays).get(id)!;
|
||||
selected.overlays[overlay.extensionName][id] = value;
|
||||
});
|
||||
return selected;
|
||||
});
|
||||
}
|
||||
|
||||
isLayerFromExtension = derived(this._overlays, ($overlays) => {
|
||||
return (id: string) => $overlays.has(id);
|
||||
});
|
||||
|
||||
getLayerName = derived(this._overlays, ($overlays) => {
|
||||
return (id: string) => $overlays.get(id)?.name || '';
|
||||
});
|
||||
|
||||
private getOverlayId(id: string): string {
|
||||
return `extension-${id}`;
|
||||
}
|
||||
|
||||
private destroy() {
|
||||
const ids = Array.from(get(this._overlays).keys());
|
||||
currentOverlays.update((current) => {
|
||||
ids.forEach((id) => {
|
||||
remove(current, id);
|
||||
});
|
||||
return current;
|
||||
});
|
||||
previousOverlays.update((previous) => {
|
||||
ids.forEach((id) => {
|
||||
remove(previous, id);
|
||||
});
|
||||
return previous;
|
||||
});
|
||||
selectedOverlayTree.update((selected) => {
|
||||
ids.forEach((id) => {
|
||||
remove(selected, id);
|
||||
});
|
||||
return selected;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionAPI = new ExtensionAPI();
|
||||
@@ -6,6 +6,10 @@ import { overpassQueryData } from '$lib/assets/layers';
|
||||
import { MapPopup } from '$lib/components/map/map-popup';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { db } from '$lib/db';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { ANCHOR_LAYER_KEY } from '../style';
|
||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||
import { loadSVGIcon } from '$lib/utils';
|
||||
|
||||
const { currentOverpassQueries } = settings;
|
||||
|
||||
@@ -20,11 +24,12 @@ liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
|
||||
});
|
||||
|
||||
export class OverpassLayer {
|
||||
overpassUrl = 'https://overpass.private.coffee/api/interpreter';
|
||||
overpassUrl = 'https://maps.mail.ru/osm/tools/overpass/api/interpreter';
|
||||
minZoom = 12;
|
||||
queryZoom = 12;
|
||||
expirationTime = 7 * 24 * 3600 * 1000;
|
||||
map: mapboxgl.Map;
|
||||
map: maplibregl.Map;
|
||||
layerEventManager: MapLayerEventManager;
|
||||
popup: MapPopup;
|
||||
|
||||
currentQueries: Set<string> = new Set();
|
||||
@@ -35,8 +40,9 @@ export class OverpassLayer {
|
||||
updateBinded = this.update.bind(this);
|
||||
onHoverBinded = this.onHover.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map) {
|
||||
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
|
||||
this.map = map;
|
||||
this.layerEventManager = layerEventManager;
|
||||
this.popup = new MapPopup(map, {
|
||||
closeButton: false,
|
||||
focusAfterOpen: false,
|
||||
@@ -47,7 +53,7 @@ export class OverpassLayer {
|
||||
|
||||
add() {
|
||||
this.map.on('moveend', this.queryIfNeededBinded);
|
||||
this.map.on('style.import.load', this.updateBinded);
|
||||
this.map.on('style.load', this.updateBinded);
|
||||
this.unsubscribes.push(data.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(
|
||||
currentOverpassQueries.subscribe(() => {
|
||||
@@ -71,10 +77,17 @@ export class OverpassLayer {
|
||||
update() {
|
||||
this.loadIcons();
|
||||
|
||||
let d = get(data);
|
||||
const fullData = get(data);
|
||||
const queries = getCurrentQueries();
|
||||
const d: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: fullData.features.filter((feature) =>
|
||||
queries.includes(feature.properties!.query)
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
let source = this.map.getSource('overpass');
|
||||
let source = this.map.getSource('overpass') as GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
source.setData(d);
|
||||
} else {
|
||||
@@ -85,23 +98,24 @@ 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.map.on('mouseenter', 'overpass', this.onHoverBinded);
|
||||
this.map.on('click', 'overpass', this.onHoverBinded);
|
||||
this.layerEventManager.on('mouseenter', 'overpass', this.onHoverBinded);
|
||||
this.layerEventManager.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
|
||||
}
|
||||
@@ -109,7 +123,9 @@ export class OverpassLayer {
|
||||
|
||||
remove() {
|
||||
this.map.off('moveend', this.queryIfNeededBinded);
|
||||
this.map.off('style.import.load', this.updateBinded);
|
||||
this.map.off('style.load', this.updateBinded);
|
||||
this.layerEventManager.off('mouseenter', 'overpass', this.onHoverBinded);
|
||||
this.layerEventManager.off('click', 'overpass', this.onHoverBinded);
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
try {
|
||||
@@ -242,27 +258,16 @@ export class OverpassLayer {
|
||||
loadIcons() {
|
||||
let currentQueries = getCurrentQueries();
|
||||
currentQueries.forEach((query) => {
|
||||
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">
|
||||
loadSVGIcon(
|
||||
this.map,
|
||||
`overpass-${query}`,
|
||||
`<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>`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -283,8 +288,10 @@ function getQuery(query: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function getQueryItem(tags: Record<string, string | boolean | string[]>) {
|
||||
let arrayEntry = Object.entries(tags).find(([_, value]) => Array.isArray(value));
|
||||
function getQueryItem(tags: Record<string, string | string[]>) {
|
||||
let arrayEntry = Object.entries(tags).find((entry): entry is [string, string[]] =>
|
||||
Array.isArray(entry[1])
|
||||
);
|
||||
if (arrayEntry !== undefined) {
|
||||
return arrayEntry[1]
|
||||
.map(
|
||||
@@ -309,7 +316,7 @@ function belongsToQuery(element: any, query: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) {
|
||||
function belongsToQueryItem(element: any, tags: Record<string, string | string[]>) {
|
||||
return Object.entries(tags).every(([tag, value]) =>
|
||||
Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value
|
||||
);
|
||||
|
||||
@@ -55,4 +55,24 @@ export function toggle(node: LayerTreeType, id: string) {
|
||||
return node;
|
||||
}
|
||||
|
||||
export const customBasemapUpdate = writable(0);
|
||||
export function remove(node: LayerTreeType, id: string) {
|
||||
Object.keys(node).forEach((key) => {
|
||||
if (key === id) {
|
||||
delete node[key];
|
||||
} else if (typeof node[key] !== 'boolean') {
|
||||
remove(node[key], id);
|
||||
}
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
export function removeAll(node: LayerTreeType, ids: string[]) {
|
||||
Object.keys(node).forEach((key) => {
|
||||
if (ids.includes(key)) {
|
||||
delete node[key];
|
||||
} else if (typeof node[key] !== 'boolean') {
|
||||
removeAll(node[key], ids);
|
||||
}
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
281
website/src/lib/components/map/map-layer-event-manager.ts
Normal file
281
website/src/lib/components/map/map-layer-event-manager.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
|
||||
type MapLayerMouseEventListener = (e: maplibregl.MapLayerMouseEvent) => void;
|
||||
type MapLayerTouchEventListener = (e: maplibregl.MapLayerTouchEvent) => void;
|
||||
type MapLayerListener = {
|
||||
features: maplibregl.MapGeoJSONFeature[];
|
||||
mousemoves: MapLayerMouseEventListener[];
|
||||
mouseenters: MapLayerMouseEventListener[];
|
||||
mouseleaves: MapLayerMouseEventListener[];
|
||||
mousedowns: MapLayerMouseEventListener[];
|
||||
clicks: MapLayerMouseEventListener[];
|
||||
contextmenus: MapLayerMouseEventListener[];
|
||||
touchstarts: MapLayerTouchEventListener[];
|
||||
};
|
||||
|
||||
export class MapLayerEventManager {
|
||||
private _map: maplibregl.Map;
|
||||
private _listeners: Record<string, MapLayerListener> = {};
|
||||
|
||||
constructor(map: maplibregl.Map) {
|
||||
this._map = map;
|
||||
this._map.on('mousemove', this._handleMouseMove.bind(this));
|
||||
this._map.on('click', this._handleMouseClick.bind(this, 'click'));
|
||||
this._map.on('contextmenu', this._handleMouseClick.bind(this, 'contextmenu'));
|
||||
this._map.on('mousedown', this._handleMouseClick.bind(this, 'mousedown'));
|
||||
this._map.on('touchstart', this._handleTouchStart.bind(this));
|
||||
}
|
||||
|
||||
on(
|
||||
eventType:
|
||||
| 'mousemove'
|
||||
| 'mouseenter'
|
||||
| 'mouseleave'
|
||||
| 'mousedown'
|
||||
| 'click'
|
||||
| 'contextmenu'
|
||||
| 'touchstart',
|
||||
|
||||
layerId: string,
|
||||
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
|
||||
) {
|
||||
if (!this._listeners[layerId]) {
|
||||
this._listeners[layerId] = {
|
||||
features: [],
|
||||
mousemoves: [],
|
||||
mouseenters: [],
|
||||
mouseleaves: [],
|
||||
mousedowns: [],
|
||||
clicks: [],
|
||||
contextmenus: [],
|
||||
touchstarts: [],
|
||||
};
|
||||
}
|
||||
switch (eventType) {
|
||||
case 'mousemove':
|
||||
this._listeners[layerId].mousemoves.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'mouseenter':
|
||||
this._listeners[layerId].mouseenters.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'mouseleave':
|
||||
this._listeners[layerId].mouseleaves.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'mousedown':
|
||||
this._listeners[layerId].mousedowns.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'click':
|
||||
this._listeners[layerId].clicks.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'contextmenu':
|
||||
this._listeners[layerId].contextmenus.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'touchstart':
|
||||
this._listeners[layerId].touchstarts.push(listener as MapLayerTouchEventListener);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
off(
|
||||
eventType:
|
||||
| 'mousemove'
|
||||
| 'mouseenter'
|
||||
| 'mouseleave'
|
||||
| 'mousedown'
|
||||
| 'click'
|
||||
| 'contextmenu'
|
||||
| 'touchstart',
|
||||
layerId: string,
|
||||
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
|
||||
) {
|
||||
if (this._listeners[layerId]) {
|
||||
switch (eventType) {
|
||||
case 'mousemove':
|
||||
this._listeners[layerId].mousemoves = this._listeners[
|
||||
layerId
|
||||
].mousemoves.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'mouseenter':
|
||||
this._listeners[layerId].mouseenters = this._listeners[
|
||||
layerId
|
||||
].mouseenters.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'mouseleave':
|
||||
this._listeners[layerId].mouseleaves = this._listeners[
|
||||
layerId
|
||||
].mouseleaves.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'mousedown':
|
||||
this._listeners[layerId].mousedowns = this._listeners[
|
||||
layerId
|
||||
].mousedowns.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'click':
|
||||
this._listeners[layerId].clicks = this._listeners[layerId].clicks.filter(
|
||||
(l) => l !== listener
|
||||
);
|
||||
break;
|
||||
case 'contextmenu':
|
||||
this._listeners[layerId].contextmenus = this._listeners[
|
||||
layerId
|
||||
].contextmenus.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'touchstart':
|
||||
this._listeners[layerId].touchstarts = this._listeners[
|
||||
layerId
|
||||
].touchstarts.filter((l) => l !== listener);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
this._listeners[layerId].mousemoves.length === 0 &&
|
||||
this._listeners[layerId].mouseenters.length === 0 &&
|
||||
this._listeners[layerId].mouseleaves.length === 0 &&
|
||||
this._listeners[layerId].mousedowns.length === 0 &&
|
||||
this._listeners[layerId].clicks.length === 0 &&
|
||||
this._listeners[layerId].contextmenus.length === 0 &&
|
||||
this._listeners[layerId].touchstarts.length === 0
|
||||
) {
|
||||
delete this._listeners[layerId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMouseMove(e: maplibregl.MapMouseEvent) {
|
||||
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
|
||||
Object.keys(this._listeners).forEach((layerId) => {
|
||||
const features = featuresByLayer[layerId] || [];
|
||||
const listener = this._listeners[layerId];
|
||||
if ((features.length == 0) != (listener.features.length == 0)) {
|
||||
if (features.length > 0) {
|
||||
if (listener.mouseenters.length > 0) {
|
||||
const event = new maplibregl.MapMouseEvent(
|
||||
'mouseenter',
|
||||
e.target,
|
||||
e.originalEvent,
|
||||
{
|
||||
features: featuresByLayer[layerId]!,
|
||||
}
|
||||
);
|
||||
listener.mouseenters.forEach((l) => l(event));
|
||||
}
|
||||
} else {
|
||||
if (listener.mouseleaves.length > 0) {
|
||||
const event = new maplibregl.MapMouseEvent(
|
||||
'mouseleave',
|
||||
e.target,
|
||||
e.originalEvent
|
||||
);
|
||||
listener.mouseleaves.forEach((l) => l(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (features.length > 0 && listener.mousemoves.length > 0) {
|
||||
const event = new maplibregl.MapMouseEvent('mousemove', e.target, e.originalEvent, {
|
||||
features: featuresByLayer[layerId]!,
|
||||
});
|
||||
listener.mousemoves.forEach((l) => l(event));
|
||||
}
|
||||
listener.features = features;
|
||||
});
|
||||
}
|
||||
|
||||
private _handleMouseClick(type: string, e: maplibregl.MapMouseEvent) {
|
||||
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
|
||||
Object.keys(this._listeners).forEach((layerId) => {
|
||||
const features = featuresByLayer[layerId] || [];
|
||||
const listener = this._listeners[layerId];
|
||||
if (features.length > 0) {
|
||||
if (type === 'click' && listener.clicks.length > 0) {
|
||||
const event = new maplibregl.MapMouseEvent('click', e.target, e.originalEvent, {
|
||||
features: features,
|
||||
});
|
||||
listener.clicks.forEach((l) => l(event));
|
||||
} else if (type === 'contextmenu' && listener.contextmenus.length > 0) {
|
||||
const event = new maplibregl.MapMouseEvent(
|
||||
'contextmenu',
|
||||
e.target,
|
||||
e.originalEvent,
|
||||
{
|
||||
features: features,
|
||||
}
|
||||
);
|
||||
listener.contextmenus.forEach((l) => l(event));
|
||||
} else if (type === 'mousedown' && listener.mousedowns.length > 0) {
|
||||
const event = new maplibregl.MapMouseEvent(
|
||||
'mousedown',
|
||||
e.target,
|
||||
e.originalEvent,
|
||||
{
|
||||
features: features,
|
||||
}
|
||||
);
|
||||
listener.mousedowns.forEach((l) => l(event));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _handleTouchStart(e: maplibregl.MapTouchEvent) {
|
||||
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
|
||||
Object.keys(this._listeners).forEach((layerId) => {
|
||||
const features = featuresByLayer[layerId] || [];
|
||||
const listener = this._listeners[layerId];
|
||||
if (features.length > 0) {
|
||||
const event: maplibregl.MapLayerTouchEvent = new maplibregl.MapTouchEvent(
|
||||
'touchstart',
|
||||
e.target,
|
||||
e.originalEvent
|
||||
);
|
||||
event.features = featuresByLayer[layerId]!;
|
||||
listener.touchstarts.forEach((l) => l(event));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _getBounds(point: maplibregl.Point) {
|
||||
const delta = 30;
|
||||
return new maplibregl.LngLatBounds(
|
||||
this._map.unproject([point.x - delta, point.y + delta]),
|
||||
this._map.unproject([point.x + delta, point.y - delta])
|
||||
);
|
||||
}
|
||||
|
||||
private _filterLayersIntersectingBounds(
|
||||
layerIds: string[],
|
||||
bounds: maplibregl.LngLatBounds
|
||||
): string[] {
|
||||
let result = layerIds.filter((layerId) => {
|
||||
if (!this._map.getLayer(layerId)) return false;
|
||||
const fileId = layerId.replace('-waypoints', '');
|
||||
if (fileId === layerId) {
|
||||
return fileStateCollection.getStatistics(fileId)?.intersectsBBox(bounds) ?? true;
|
||||
} else {
|
||||
return (
|
||||
fileStateCollection.getStatistics(fileId)?.intersectsWaypointBBox(bounds) ??
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private _getRenderedFeaturesByLayer(e: maplibregl.MapMouseEvent | maplibregl.MapTouchEvent) {
|
||||
const layerIds = this._filterLayersIntersectingBounds(
|
||||
Object.keys(this._listeners),
|
||||
this._getBounds(e.point)
|
||||
);
|
||||
const features =
|
||||
layerIds.length > 0
|
||||
? this._map.queryRenderedFeatures(e.point, { layers: layerIds })
|
||||
: [];
|
||||
const featuresByLayer: Record<string, maplibregl.MapGeoJSONFeature[]> = {};
|
||||
features.forEach((f) => {
|
||||
if (!featuresByLayer[f.layer.id]) {
|
||||
featuresByLayer[f.layer.id] = [];
|
||||
}
|
||||
featuresByLayer[f.layer.id].push(f);
|
||||
});
|
||||
return featuresByLayer;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TrackPoint, Waypoint } from 'gpx';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { mount, tick } from 'svelte';
|
||||
import maplibregl from 'maplibre-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: mapboxgl.Map;
|
||||
popup: mapboxgl.Popup;
|
||||
map: maplibregl.Map;
|
||||
popup: maplibregl.Popup;
|
||||
item: Writable<PopupItem | null> = writable(null);
|
||||
component: ReturnType<typeof mount>;
|
||||
maybeHideBinded = this.maybeHide.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
|
||||
constructor(map: maplibregl.Map, options?: maplibregl.PopupOptions) {
|
||||
this.map = map;
|
||||
this.popup = new mapboxgl.Popup(options);
|
||||
this.popup = new maplibregl.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: mapboxgl.MapMouseEvent) {
|
||||
maybeHide(e: maplibregl.MapMouseEvent) {
|
||||
const item = get(this.item);
|
||||
if (item === null) {
|
||||
this.hide();
|
||||
@@ -69,16 +69,16 @@ export class MapPopup {
|
||||
|
||||
remove() {
|
||||
this.popup.remove();
|
||||
this.component.$destroy();
|
||||
unmount(this.component);
|
||||
}
|
||||
|
||||
getCoordinates() {
|
||||
const item = get(this.item);
|
||||
if (item === null) {
|
||||
return new mapboxgl.LngLat(0, 0);
|
||||
return new maplibregl.LngLat(0, 0);
|
||||
}
|
||||
return item.item instanceof Waypoint || item.item instanceof TrackPoint
|
||||
? item.item.getCoordinates()
|
||||
: new mapboxgl.LngLat(item.item.lon, item.item.lat);
|
||||
: new maplibregl.LngLat(item.item.lon, item.item.lat);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +1,80 @@
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
||||
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 { 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: mapboxgl.MapOptions['fitBoundsOptions'] = {
|
||||
let fitBoundsOptions: maplibregl.MapOptions['fitBoundsOptions'] = {
|
||||
maxZoom: 15,
|
||||
linear: true,
|
||||
easing: () => 1,
|
||||
};
|
||||
|
||||
export class MapboxGLMap {
|
||||
private _map: Writable<mapboxgl.Map | null> = writable(null);
|
||||
private _onLoadCallbacks: ((map: mapboxgl.Map) => void)[] = [];
|
||||
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)[] = [];
|
||||
private _unsubscribes: (() => void)[] = [];
|
||||
private callOnLoadBinded: () => void = this.callOnLoad.bind(this);
|
||||
public layerEventManager: MapLayerEventManager | null = null;
|
||||
|
||||
subscribe(run: (value: mapboxgl.Map | null) => void, invalidate?: () => void) {
|
||||
return this._map.subscribe(run, invalidate);
|
||||
subscribe(run: (value: maplibregl.Map | null) => void, invalidate?: () => void) {
|
||||
return this._mapStore.subscribe(run, invalidate);
|
||||
}
|
||||
|
||||
init(
|
||||
accessToken: string,
|
||||
maptilerKey: string,
|
||||
language: string,
|
||||
hash: boolean,
|
||||
geocoder: boolean,
|
||||
geolocate: boolean
|
||||
) {
|
||||
const map = new mapboxgl.Map({
|
||||
this._maptilerKey = maptilerKey;
|
||||
this._styleManager = new StyleManager(this._mapStore, this._maptilerKey);
|
||||
const map = new maplibregl.Map({
|
||||
container: 'map',
|
||||
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: 85,
|
||||
});
|
||||
this.layerEventManager = new MapLayerEventManager(map);
|
||||
map.addControl(
|
||||
new mapboxgl.AttributionControl({
|
||||
compact: true,
|
||||
})
|
||||
);
|
||||
map.addControl(
|
||||
new mapboxgl.NavigationControl({
|
||||
new maplibregl.NavigationControl({
|
||||
visualizePitch: true,
|
||||
})
|
||||
);
|
||||
if (geocoder) {
|
||||
let geocoder = new MapboxGeocoder({
|
||||
mapboxgl: mapboxgl,
|
||||
enableEventLogging: false,
|
||||
collapsed: true,
|
||||
flyTo: fitBoundsOptions,
|
||||
language,
|
||||
localGeocoder: () => [],
|
||||
localGeocoderOnly: true,
|
||||
externalGeocoder: (query: string) =>
|
||||
fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
return data.map((result: any) => {
|
||||
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) => {
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
@@ -104,73 +84,43 @@ export class MapboxGLMap {
|
||||
place_name: result.display_name,
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
let onKeyDown = geocoder._onKeyDown;
|
||||
geocoder._onKeyDown = (e: KeyboardEvent) => {
|
||||
// Trigger search on Enter key only
|
||||
if (e.key === 'Enter') {
|
||||
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
|
||||
} else if (geocoder._typeahead.data.length > 0) {
|
||||
geocoder._typeahead.clear();
|
||||
} catch (e) {}
|
||||
return results;
|
||||
},
|
||||
},
|
||||
{
|
||||
maplibregl: maplibregl,
|
||||
enableEventLogging: false,
|
||||
collapsed: true,
|
||||
flyTo: fitBoundsOptions,
|
||||
language,
|
||||
}
|
||||
};
|
||||
);
|
||||
map.addControl(geocoder);
|
||||
}
|
||||
if (geolocate) {
|
||||
map.addControl(
|
||||
new mapboxgl.GeolocateControl({
|
||||
new maplibregl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
fitBoundsOptions,
|
||||
trackUserLocation: true,
|
||||
showUserHeading: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
const scaleControl = new mapboxgl.ScaleControl({
|
||||
const scaleControl = new maplibregl.ScaleControl({
|
||||
unit: get(distanceUnits),
|
||||
});
|
||||
map.addControl(scaleControl);
|
||||
map.on('style.load', () => {
|
||||
map.addSource('mapbox-dem', {
|
||||
type: 'raster-dem',
|
||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||
tileSize: 512,
|
||||
maxzoom: 14,
|
||||
});
|
||||
if (map.getPitch() > 0) {
|
||||
map.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 1,
|
||||
});
|
||||
}
|
||||
map.setFog({
|
||||
color: 'rgb(186, 210, 235)',
|
||||
'high-color': 'rgb(36, 92, 223)',
|
||||
'horizon-blend': 0.1,
|
||||
'space-color': 'rgb(156, 240, 255)',
|
||||
});
|
||||
map.on('pitch', () => {
|
||||
if (map.getPitch() > 0) {
|
||||
map.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 1,
|
||||
});
|
||||
} else {
|
||||
map.setTerrain(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
map.on('load', () => {
|
||||
this._map.set(map); // only set the store after the map has loaded
|
||||
this._map = map;
|
||||
this._mapStore.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()));
|
||||
@@ -183,44 +133,48 @@ export class MapboxGLMap {
|
||||
);
|
||||
}
|
||||
|
||||
onLoad(callback: (map: mapboxgl.Map) => void) {
|
||||
const map = get(this._map);
|
||||
if (map) {
|
||||
callback(map);
|
||||
} else {
|
||||
this._onLoadCallbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
const map = get(this._map);
|
||||
if (map) {
|
||||
map.remove();
|
||||
this._map.set(null);
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
this._mapStore.set(null);
|
||||
}
|
||||
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
this._unsubscribes = [];
|
||||
}
|
||||
|
||||
resize() {
|
||||
const map = get(this._map);
|
||||
if (map) {
|
||||
if (this._map) {
|
||||
tick().then(() => {
|
||||
map.resize();
|
||||
this._map?.resize();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggle3D() {
|
||||
const map = get(this._map);
|
||||
if (map) {
|
||||
if (map.getPitch() === 0) {
|
||||
map.easeTo({ pitch: 70 });
|
||||
if (this._map) {
|
||||
if (this._map.getPitch() === 0) {
|
||||
this._map.easeTo({ pitch: 70 });
|
||||
} else {
|
||||
map.easeTo({ pitch: 0 });
|
||||
this._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 MapboxGLMap();
|
||||
export const map = new MapLibreGLMap();
|
||||
|
||||
@@ -20,9 +20,14 @@
|
||||
let container: HTMLElement;
|
||||
|
||||
onMount(() => {
|
||||
map.onLoad((map: mapboxgl.Map) => {
|
||||
googleRedirect = new GoogleRedirect(map);
|
||||
mapillaryLayer = new MapillaryLayer(map, container, mapillaryOpen);
|
||||
map.onLoad((map_: maplibregl.Map) => {
|
||||
googleRedirect = new GoogleRedirect(map_);
|
||||
mapillaryLayer = new MapillaryLayer(
|
||||
map_,
|
||||
map.layerEventManager!,
|
||||
container,
|
||||
mapillaryOpen
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import type mapboxgl from 'mapbox-gl';
|
||||
|
||||
export class GoogleRedirect {
|
||||
map: mapboxgl.Map;
|
||||
map: maplibregl.Map;
|
||||
enabled = false;
|
||||
|
||||
constructor(map: mapboxgl.Map) {
|
||||
constructor(map: maplibregl.Map) {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
@@ -25,7 +24,7 @@ export class GoogleRedirect {
|
||||
this.map.off('click', this.openStreetView);
|
||||
}
|
||||
|
||||
openStreetView(e: mapboxgl.MapMouseEvent) {
|
||||
openStreetView(e: maplibregl.MapMouseEvent) {
|
||||
window.open(
|
||||
`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${e.lngLat.lat},${e.lngLat.lng}`
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from 'mapbox-gl';
|
||||
import maplibregl, { type LayerSpecification, type VectorSourceSpecification } from 'maplibre-gl';
|
||||
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
||||
import 'mapillary-js/dist/mapillary.css';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { ANCHOR_LAYER_KEY } from '../style';
|
||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||
|
||||
const mapillarySource: VectorSourceSpecification = {
|
||||
type: 'vector',
|
||||
@@ -41,8 +43,9 @@ const mapillaryImageLayer: LayerSpecification = {
|
||||
};
|
||||
|
||||
export class MapillaryLayer {
|
||||
map: mapboxgl.Map;
|
||||
marker: mapboxgl.Marker;
|
||||
map: maplibregl.Map;
|
||||
layerEventManager: MapLayerEventManager;
|
||||
marker: maplibregl.Marker;
|
||||
viewer: Viewer;
|
||||
|
||||
active = false;
|
||||
@@ -52,8 +55,14 @@ export class MapillaryLayer {
|
||||
onMouseEnterBinded = this.onMouseEnter.bind(this);
|
||||
onMouseLeaveBinded = this.onMouseLeave.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: { value: boolean }) {
|
||||
constructor(
|
||||
map: maplibregl.Map,
|
||||
layerEventManager: MapLayerEventManager,
|
||||
container: HTMLElement,
|
||||
popupOpen: { value: boolean }
|
||||
) {
|
||||
this.map = map;
|
||||
this.layerEventManager = layerEventManager;
|
||||
|
||||
this.viewer = new Viewer({
|
||||
accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011',
|
||||
@@ -61,15 +70,12 @@ export class MapillaryLayer {
|
||||
});
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.className = 'mapboxgl-user-location mapboxgl-user-location-show-heading';
|
||||
element.className = 'maplibregl-user-location maplibregl-user-location-show-heading';
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'mapboxgl-user-location-dot';
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'mapboxgl-user-location-heading';
|
||||
dot.className = 'maplibregl-user-location-dot';
|
||||
element.appendChild(dot);
|
||||
element.appendChild(heading);
|
||||
|
||||
this.marker = new mapboxgl.Marker({
|
||||
this.marker = new maplibregl.Marker({
|
||||
rotationAlignment: 'map',
|
||||
element,
|
||||
});
|
||||
@@ -99,20 +105,20 @@ export class MapillaryLayer {
|
||||
this.map.addSource('mapillary', mapillarySource);
|
||||
}
|
||||
if (!this.map.getLayer('mapillary-sequence')) {
|
||||
this.map.addLayer(mapillarySequenceLayer);
|
||||
this.map.addLayer(mapillarySequenceLayer, ANCHOR_LAYER_KEY.mapillary);
|
||||
}
|
||||
if (!this.map.getLayer('mapillary-image')) {
|
||||
this.map.addLayer(mapillaryImageLayer);
|
||||
this.map.addLayer(mapillaryImageLayer, ANCHOR_LAYER_KEY.mapillary);
|
||||
}
|
||||
this.map.on('style.load', this.addBinded);
|
||||
this.map.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||
this.map.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
||||
this.layerEventManager.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||
this.layerEventManager.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.map.off('style.load', this.addBinded);
|
||||
this.map.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||
this.map.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
||||
this.layerEventManager.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||
this.layerEventManager.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
||||
|
||||
if (this.map.getLayer('mapillary-image')) {
|
||||
this.map.removeLayer('mapillary-image');
|
||||
@@ -134,13 +140,20 @@ export class MapillaryLayer {
|
||||
this.popupOpen.value = false;
|
||||
}
|
||||
|
||||
onMouseEnter(e: mapboxgl.MapMouseEvent) {
|
||||
this.active = true;
|
||||
onMouseEnter(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (
|
||||
e.features &&
|
||||
e.features.length > 0 &&
|
||||
e.features[0].properties &&
|
||||
e.features[0].properties.id
|
||||
) {
|
||||
this.active = true;
|
||||
|
||||
this.viewer.resize();
|
||||
this.viewer.moveTo(e.features[0].properties.id);
|
||||
this.viewer.resize();
|
||||
this.viewer.moveTo(e.features[0].properties.id);
|
||||
|
||||
mapCursor.notify(MapCursorState.MAPILLARY_HOVER, true);
|
||||
mapCursor.notify(MapCursorState.MAPILLARY_HOVER, true);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
|
||||
231
website/src/lib/components/map/style.ts
Normal file
231
website/src/lib/components/map/style.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { get, type Writable } from 'svelte/store';
|
||||
import {
|
||||
basemaps,
|
||||
defaultBasemap,
|
||||
maptilerKeyPlaceHolder,
|
||||
overlays,
|
||||
terrainSources,
|
||||
} from '$lib/assets/layers';
|
||||
import { getLayers } from '$lib/components/map/layer-control/utils';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
|
||||
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource } = settings;
|
||||
|
||||
const emptySource: maplibregl.GeoJSONSourceSpecification = {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
},
|
||||
};
|
||||
export const ANCHOR_LAYER_KEY = {
|
||||
overlays: 'overlays-end',
|
||||
mapillary: 'mapillary-end',
|
||||
tracks: 'tracks-end',
|
||||
directionMarkers: 'direction-markers-end',
|
||||
distanceMarkers: 'distance-markers-end',
|
||||
startEndMarkers: 'start-end-markers-end',
|
||||
interactions: 'interactions-end',
|
||||
overpass: 'overpass-end',
|
||||
waypoints: 'waypoints-end',
|
||||
routingControls: 'routing-controls-end',
|
||||
};
|
||||
const anchorLayers: maplibregl.LayerSpecification[] = Object.values(ANCHOR_LAYER_KEY).map((id) => ({
|
||||
id: id,
|
||||
type: 'symbol',
|
||||
source: 'empty-source',
|
||||
}));
|
||||
|
||||
export class StyleManager {
|
||||
private _map: Writable<maplibregl.Map | null>;
|
||||
private _maptilerKey: string;
|
||||
private _pastOverlays: Set<string> = new Set();
|
||||
|
||||
constructor(map: Writable<maplibregl.Map | null>, maptilerKey: string) {
|
||||
this._map = map;
|
||||
this._maptilerKey = maptilerKey;
|
||||
this._map.subscribe((map_) => {
|
||||
if (map_) {
|
||||
this.updateBasemap();
|
||||
map_.on('style.load', () => this.updateOverlays());
|
||||
map_.on('pitch', () => this.updateTerrain());
|
||||
}
|
||||
});
|
||||
currentBasemap.subscribe(() => this.updateBasemap());
|
||||
currentOverlays.subscribe(() => this.updateOverlays());
|
||||
opacities.subscribe(() => this.updateOverlays());
|
||||
terrainSource.subscribe(() => this.updateTerrain());
|
||||
customLayers.subscribe(() => this.updateBasemap());
|
||||
}
|
||||
|
||||
updateBasemap() {
|
||||
const map_ = get(this._map);
|
||||
if (!map_) return;
|
||||
this.buildStyle().then((style) => map_.setStyle(style));
|
||||
}
|
||||
|
||||
async buildStyle(): Promise<maplibregl.StyleSpecification> {
|
||||
const custom = get(customLayers);
|
||||
|
||||
const style: maplibregl.StyleSpecification = {
|
||||
version: 8,
|
||||
projection: {
|
||||
type: 'globe',
|
||||
},
|
||||
sources: {
|
||||
'empty-source': emptySource,
|
||||
},
|
||||
layers: [],
|
||||
};
|
||||
|
||||
let basemap = get(currentBasemap);
|
||||
const basemapInfo = basemaps[basemap] ?? custom[basemap]?.value ?? basemaps[defaultBasemap];
|
||||
const basemapStyle = await this.get(basemapInfo);
|
||||
|
||||
this.merge(style, basemapStyle);
|
||||
|
||||
const terrain = this.getCurrentTerrain();
|
||||
style.sources[terrain.source] = terrainSources[terrain.source];
|
||||
style.terrain = terrain.exaggeration > 0 ? terrain : undefined;
|
||||
|
||||
style.layers.push(...anchorLayers);
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
async updateOverlays() {
|
||||
const map_ = get(this._map);
|
||||
if (!map_) return;
|
||||
if (!map_.getSource('empty-source')) return;
|
||||
|
||||
const custom = get(customLayers);
|
||||
const overlayOpacities = get(opacities);
|
||||
try {
|
||||
const layers = getLayers(get(currentOverlays) ?? {});
|
||||
for (let overlay in layers) {
|
||||
if (!layers[overlay]) {
|
||||
if (this._pastOverlays.has(overlay)) {
|
||||
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
|
||||
const overlayStyle = await this.get(overlayInfo);
|
||||
for (let layer of overlayStyle.layers ?? []) {
|
||||
if (map_.getLayer(layer.id)) {
|
||||
map_.removeLayer(layer.id);
|
||||
}
|
||||
}
|
||||
this._pastOverlays.delete(overlay);
|
||||
}
|
||||
} else {
|
||||
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
|
||||
const overlayStyle = await this.get(overlayInfo);
|
||||
const opacity = overlayOpacities[overlay];
|
||||
|
||||
for (let sourceId in overlayStyle.sources) {
|
||||
if (!map_.getSource(sourceId)) {
|
||||
map_.addSource(sourceId, overlayStyle.sources[sourceId]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let layer of overlayStyle.layers ?? []) {
|
||||
if (!map_.getLayer(layer.id)) {
|
||||
if (opacity !== undefined) {
|
||||
if (layer.type === 'raster') {
|
||||
if (!layer.paint) {
|
||||
layer.paint = {};
|
||||
}
|
||||
layer.paint['raster-opacity'] = opacity;
|
||||
} else if (layer.type === 'hillshade') {
|
||||
if (!layer.paint) {
|
||||
layer.paint = {};
|
||||
}
|
||||
layer.paint['hillshade-exaggeration'] = opacity / 2;
|
||||
}
|
||||
}
|
||||
map_.addLayer(layer, ANCHOR_LAYER_KEY.overlays);
|
||||
}
|
||||
}
|
||||
|
||||
this._pastOverlays.add(overlay);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
updateTerrain() {
|
||||
const map_ = get(this._map);
|
||||
if (!map_) return;
|
||||
|
||||
const mapTerrain = map_.getTerrain();
|
||||
const terrain = this.getCurrentTerrain();
|
||||
if (JSON.stringify(mapTerrain) !== JSON.stringify(terrain)) {
|
||||
if (terrain.exaggeration > 0) {
|
||||
if (!map_.getSource(terrain.source)) {
|
||||
map_.addSource(terrain.source, terrainSources[terrain.source]);
|
||||
}
|
||||
map_.setTerrain(terrain);
|
||||
} else {
|
||||
map_.setTerrain(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async get(
|
||||
styleInfo: maplibregl.StyleSpecification | string
|
||||
): Promise<maplibregl.StyleSpecification> {
|
||||
if (typeof styleInfo === 'string') {
|
||||
let styleUrl = styleInfo as string;
|
||||
if (styleUrl.includes(maptilerKeyPlaceHolder)) {
|
||||
styleUrl = styleUrl.replace(maptilerKeyPlaceHolder, this._maptilerKey);
|
||||
}
|
||||
const response = await fetch(styleUrl, { cache: 'force-cache' });
|
||||
const style = await response.json();
|
||||
return style;
|
||||
} else {
|
||||
return styleInfo;
|
||||
}
|
||||
}
|
||||
|
||||
merge(style: maplibregl.StyleSpecification, other: maplibregl.StyleSpecification) {
|
||||
style.sources = { ...style.sources, ...other.sources };
|
||||
for (let layer of other.layers ?? []) {
|
||||
if (layer.type === 'symbol' && layer.layout && layer.layout['text-field']) {
|
||||
const textField = layer.layout['text-field'];
|
||||
if (
|
||||
Array.isArray(textField) &&
|
||||
textField.length >= 2 &&
|
||||
textField[0] === 'coalesce' &&
|
||||
Array.isArray(textField[1]) &&
|
||||
textField[1][0] === 'get' &&
|
||||
typeof textField[1][1] === 'string' &&
|
||||
textField[1][1].startsWith('name')
|
||||
) {
|
||||
layer.layout['text-field'] = [
|
||||
'coalesce',
|
||||
['get', `name:${i18n.lang}`],
|
||||
['get', 'name'],
|
||||
];
|
||||
}
|
||||
}
|
||||
style.layers.push(layer);
|
||||
}
|
||||
if (other.sprite && !style.sprite) {
|
||||
style.sprite = other.sprite;
|
||||
}
|
||||
if (other.glyphs && !style.glyphs) {
|
||||
style.glyphs = other.glyphs;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentTerrain() {
|
||||
const terrain = get(terrainSource);
|
||||
const source = terrainSources[terrain];
|
||||
if (source.url && source.url.includes(maptilerKeyPlaceHolder)) {
|
||||
source.url = source.url.replace(maptilerKeyPlaceHolder, this._maptilerKey);
|
||||
}
|
||||
const map_ = get(this._map);
|
||||
return {
|
||||
source: terrain,
|
||||
exaggeration: !map_ || map_.getPitch() === 0 ? 0 : 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
|
||||
import Reduce from '$lib/components/toolbar/tools/reduce/Reduce.svelte';
|
||||
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
|
||||
let {
|
||||
@@ -23,11 +23,11 @@
|
||||
const { minimizeRoutingMenu } = settings;
|
||||
|
||||
let popupElement: HTMLDivElement | undefined = $state(undefined);
|
||||
let popup: mapboxgl.Popup | undefined = $derived.by(() => {
|
||||
let popup: maplibregl.Popup | undefined = $derived.by(() => {
|
||||
if (!popupElement) {
|
||||
return undefined;
|
||||
}
|
||||
let popup = new mapboxgl.Popup({
|
||||
let popup = new maplibregl.Popup({
|
||||
closeButton: false,
|
||||
maxWidth: undefined,
|
||||
});
|
||||
@@ -38,7 +38,9 @@
|
||||
</script>
|
||||
|
||||
{#if $currentTool !== null}
|
||||
<div class="translate-x-1 h-full animate-in animate-out {className}">
|
||||
<div
|
||||
class="translate-x-1 h-full animate-in fade-in-0 zoom-in-95 slide-in-from-left-2 {className}"
|
||||
>
|
||||
<div class="rounded-md shadow-md pointer-events-auto">
|
||||
<Card.Root class="rounded-md border-none py-2.5">
|
||||
<Card.Content class="px-2.5">
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import type { GeoJSONSource } from 'mapbox-gl';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { 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;
|
||||
@@ -28,7 +29,7 @@
|
||||
let cleanType = $state(CleanType.INSIDE);
|
||||
let deleteTrackpoints = $state(true);
|
||||
let deleteWaypoints = $state(true);
|
||||
let rectangleCoordinates: mapboxgl.LngLat[] = $state([]);
|
||||
let rectangleCoordinates: maplibregl.LngLat[] = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
if ($map) {
|
||||
@@ -63,15 +64,18 @@
|
||||
});
|
||||
}
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,7 +181,7 @@
|
||||
rectangleCoordinates = [];
|
||||
}}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
<Trash2 size="16" />
|
||||
{i18n._('toolbar.clean.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/clean')}>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
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';
|
||||
@@ -20,13 +19,9 @@
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit"
|
||||
disabled={!validSelection}
|
||||
onclick={async () => {
|
||||
if ($map) {
|
||||
fileActions.addElevationToSelection($map);
|
||||
}
|
||||
}}
|
||||
onclick={() => fileActions.addElevationToSelection()}
|
||||
>
|
||||
<MountainSnow size="16" class="mr-1 shrink-0" />
|
||||
<MountainSnow size="16" class="shrink-0" />
|
||||
{i18n._('toolbar.elevation.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/elevation')}>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
|
||||
<Button variant="outline" disabled={!validSelection} onclick={fileActions.extractSelection}>
|
||||
<Ungroup size="16" class="mr-1" />
|
||||
<Ungroup size="16" />
|
||||
{i18n._('toolbar.extract.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/extract')}>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Group size="16" class="mr-1 shrink-0" />
|
||||
<Group size="16" class="shrink-0" />
|
||||
{i18n._('toolbar.merge.merge_selection')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/merge')}>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
} from '$lib/units';
|
||||
import { CalendarDate, type DateValue } from '@internationalized/date';
|
||||
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from '@lucide/svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
@@ -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(false);
|
||||
let artificial = $state(true);
|
||||
|
||||
function toCalendarDate(date: Date): CalendarDate {
|
||||
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
$effect(() => {
|
||||
if ($gpxStatistics && $velocityUnits && $distanceUnits) {
|
||||
setGPXData();
|
||||
untrack(() => setGPXData());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
<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" class="mr-1" />
|
||||
<Zap size="16" />
|
||||
{#if $velocityUnits === 'speed'}
|
||||
{i18n._('quantities.speed')}
|
||||
{:else}
|
||||
@@ -204,7 +204,9 @@
|
||||
min={0.01}
|
||||
disabled={!canUpdate}
|
||||
bind:value={speed}
|
||||
onchange={updateDataFromSpeed}
|
||||
onchange={() => {
|
||||
untrack(() => updateDataFromSpeed());
|
||||
}}
|
||||
class="text-sm"
|
||||
/>
|
||||
<span class="text-sm shrink-0">
|
||||
@@ -221,7 +223,9 @@
|
||||
bind:value={speed}
|
||||
showHours={false}
|
||||
disabled={!canUpdate}
|
||||
onChange={updateDataFromSpeed}
|
||||
onChange={() => {
|
||||
untrack(() => updateDataFromSpeed());
|
||||
}}
|
||||
/>
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
@@ -237,18 +241,20 @@
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<Label for="duration" class="flex flex-row">
|
||||
<Timer size="16" class="mr-1" />
|
||||
<Timer size="16" />
|
||||
{i18n._('toolbar.time.total_time')}
|
||||
</Label>
|
||||
<TimePicker
|
||||
bind:value={movingTime}
|
||||
disabled={!canUpdate}
|
||||
onChange={updateDataFromTotalTime}
|
||||
onChange={() => {
|
||||
untrack(() => updateDataFromTotalTime());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Label class="flex flex-row">
|
||||
<CirclePlay size="16" class="mr-1" />
|
||||
<CirclePlay size="16" />
|
||||
{i18n._('toolbar.time.start')}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
@@ -258,22 +264,23 @@
|
||||
locale={i18n.lang}
|
||||
placeholder={i18n._('toolbar.time.pick_date')}
|
||||
class="w-fit grow"
|
||||
onValueChange={async () => {
|
||||
await tick();
|
||||
updateEnd();
|
||||
onchange={() => {
|
||||
untrack(() => updateEnd());
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
type="time"
|
||||
step={1}
|
||||
disabled={!canUpdate}
|
||||
bind:value={startTime}
|
||||
class="w-fit"
|
||||
onchange={updateEnd}
|
||||
onchange={() => {
|
||||
untrack(() => updateEnd());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Label class="flex flex-row">
|
||||
<CircleStop size="16" class="mr-1" />
|
||||
<CircleStop size="16" />
|
||||
{i18n._('toolbar.time.end')}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
@@ -283,18 +290,19 @@
|
||||
locale={i18n.lang}
|
||||
placeholder={i18n._('toolbar.time.pick_date')}
|
||||
class="w-fit grow"
|
||||
onValueChange={async () => {
|
||||
await tick();
|
||||
updateStart();
|
||||
onchange={() => {
|
||||
untrack(() => updateStart());
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
type="time"
|
||||
step={1}
|
||||
disabled={!canUpdate}
|
||||
bind:value={endTime}
|
||||
class="w-fit"
|
||||
onchange={updateStart}
|
||||
onchange={() => {
|
||||
untrack(() => updateStart());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
|
||||
@@ -316,7 +324,8 @@
|
||||
if (
|
||||
startDate === undefined ||
|
||||
startTime === undefined ||
|
||||
effectiveSpeed === undefined
|
||||
effectiveSpeed === undefined ||
|
||||
movingTime === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -337,44 +346,44 @@
|
||||
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
|
||||
getDate(startDate!, startTime!),
|
||||
movingTime!
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
getDate(startDate!, startTime!),
|
||||
effectiveSpeed,
|
||||
ratio
|
||||
);
|
||||
}
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
if (artificial || !$gpxStatistics.global.time.moving) {
|
||||
if (artificial && !$gpxStatistics.global.time.moving) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
movingTime,
|
||||
getDate(startDate!, startTime!),
|
||||
movingTime!,
|
||||
item.getTrackIndex()
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
getDate(startDate!, startTime!),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex()
|
||||
);
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
if (artificial || !$gpxStatistics.global.time.moving) {
|
||||
if (artificial && !$gpxStatistics.global.time.moving) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
movingTime,
|
||||
getDate(startDate!, startTime!),
|
||||
movingTime!,
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex()
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
getDate(startDate!, startTime!),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex(),
|
||||
@@ -385,10 +394,10 @@
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CalendarClock size="16" class="mr-1 shrink-0" />
|
||||
<CalendarClock size="16" class="shrink-0" />
|
||||
{i18n._('toolbar.time.update')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={setGPXData}>
|
||||
<Button variant="outline" size="icon" onclick={setGPXData}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -400,15 +409,3 @@
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
@reference "../../../../app.css";
|
||||
|
||||
div :global(input[type='time']) {
|
||||
/*
|
||||
Style copy-pasted from shadcn-svelte Input.
|
||||
Needed to use native time input to avoid a bug with 2-level bind:value.
|
||||
*/
|
||||
@apply flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import { ListItem, ListRootItem } from '$lib/components/file-list/file-list';
|
||||
import { ListRootItem } from '$lib/components/file-list/file-list';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { Funnel } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
@@ -10,13 +10,11 @@
|
||||
import { onDestroy } from 'svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { minTolerance, ReducedGPXLayerCollection, tolerance } from './reduce';
|
||||
import { minTolerance, ReducedGPXLayerCollection, tolerance } from './utils.svelte';
|
||||
|
||||
let props: { class?: string } = $props();
|
||||
|
||||
let sliderValue = $state([50]);
|
||||
let maxPoints = $state(0);
|
||||
let currentPoints = $state(0);
|
||||
const maxTolerance = 10000;
|
||||
|
||||
let validSelection = $derived(
|
||||
@@ -46,10 +44,10 @@
|
||||
</Label>
|
||||
<Label class="flex flex-row justify-between">
|
||||
<span>{i18n._('toolbar.reduce.number_of_points')}</span>
|
||||
<span class="font-normal">{currentPoints}/{maxPoints}</span>
|
||||
<span class="font-normal">{reducedLayers.currentPoints}/{reducedLayers.maxPoints}</span>
|
||||
</Label>
|
||||
<Button variant="outline" disabled={!validSelection} onclick={() => reducedLayers.reduce()}>
|
||||
<Funnel size="16" class="mr-1" />
|
||||
<Funnel size="16" />
|
||||
{i18n._('toolbar.reduce.button')}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,10 +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 'mapbox-gl';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
export const minTolerance = 0.1;
|
||||
@@ -28,17 +29,15 @@ export class ReducedGPXLayer {
|
||||
|
||||
update() {
|
||||
const file = this._fileState.file;
|
||||
const stats = this._fileState.statistics;
|
||||
if (!file || !stats) {
|
||||
if (!file) {
|
||||
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,
|
||||
statistics.local.points.length,
|
||||
ramerDouglasPeucker(statistics.local.points, minTolerance),
|
||||
segment.trkpt.length,
|
||||
ramerDouglasPeucker(segment.trkpt, minTolerance),
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -53,14 +52,16 @@ export const tolerance = writable<number>(0);
|
||||
export class ReducedGPXLayerCollection {
|
||||
private _layers: Map<string, ReducedGPXLayer> = new Map();
|
||||
private _simplified: Map<string, [ListItem, number, SimplifiedTrackPoint[]]>;
|
||||
private _fileStateCollectionOberver: GPXFileStateCollectionObserver;
|
||||
private _currentPoints = $state(0);
|
||||
private _maxPoints = $state(0);
|
||||
private _fileStateCollectionObserver: GPXFileStateCollectionObserver;
|
||||
private _updateSimplified = this.updateSimplified.bind(this);
|
||||
private _unsubscribes: (() => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
this._layers = new Map();
|
||||
this._simplified = new Map();
|
||||
this._fileStateCollectionOberver = new GPXFileStateCollectionObserver(
|
||||
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
|
||||
(newFiles) => {
|
||||
newFiles.forEach((fileState, fileId) => {
|
||||
this._layers.set(
|
||||
@@ -96,8 +97,8 @@ export class ReducedGPXLayerCollection {
|
||||
}
|
||||
|
||||
update() {
|
||||
let maxPoints = 0;
|
||||
let currentPoints = 0;
|
||||
this._currentPoints = 0;
|
||||
this._maxPoints = 0;
|
||||
|
||||
let data: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
@@ -109,12 +110,12 @@ export class ReducedGPXLayerCollection {
|
||||
return;
|
||||
}
|
||||
|
||||
maxPoints += maxPts;
|
||||
this._maxPoints += maxPts;
|
||||
|
||||
let current = points.filter(
|
||||
(point) => point.distance === undefined || point.distance >= get(tolerance)
|
||||
);
|
||||
currentPoints += current.length;
|
||||
this._currentPoints += current.length;
|
||||
|
||||
data.features.push({
|
||||
type: 'Feature',
|
||||
@@ -144,17 +145,18 @@ 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
map_.moveLayer('simplified');
|
||||
ANCHOR_LAYER_KEY.interactions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,8 +175,16 @@ export class ReducedGPXLayerCollection {
|
||||
fileActions.reduce(itemsAndPoints);
|
||||
}
|
||||
|
||||
get currentPoints() {
|
||||
return this._currentPoints;
|
||||
}
|
||||
|
||||
get maxPoints() {
|
||||
return this._maxPoints;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._fileStateCollectionOberver.destroy();
|
||||
this._fileStateCollectionObserver.destroy();
|
||||
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
const map_ = get(map);
|
||||
@@ -15,13 +15,13 @@
|
||||
Route,
|
||||
TriangleAlert,
|
||||
ArrowRightLeft,
|
||||
Home,
|
||||
House,
|
||||
RouteOff,
|
||||
Repeat,
|
||||
SquareArrowUpLeft,
|
||||
SquareArrowOutDownRight,
|
||||
} from '@lucide/svelte';
|
||||
import { brouterProfiles } from '$lib/components/toolbar/tools/routing/routing';
|
||||
import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import {
|
||||
@@ -51,7 +51,7 @@
|
||||
}: {
|
||||
minimized?: boolean;
|
||||
minimizable?: boolean;
|
||||
popup?: mapboxgl.Popup;
|
||||
popup?: maplibregl.Popup;
|
||||
popupElement?: HTMLDivElement;
|
||||
class?: string;
|
||||
} = $props();
|
||||
@@ -129,7 +129,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 animate-in animate-out {className ?? ''}">
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {className ?? ''}">
|
||||
<div class="flex flex-col gap-3">
|
||||
<Label class="justify-between">
|
||||
<span class="flex flex-row items-center gap-1">
|
||||
@@ -163,11 +163,11 @@
|
||||
{i18n._('toolbar.routing.activity')}
|
||||
</span>
|
||||
<Select.Root type="single" bind:value={$routingProfile}>
|
||||
<Select.Trigger class="h-8 grow">
|
||||
<Select.Trigger class="grow" size="sm">
|
||||
{i18n._(`toolbar.routing.activities.${$routingProfile}`)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.keys(brouterProfiles) as profile}
|
||||
{#each Object.keys(routingProfiles) as profile}
|
||||
<Select.Item value={profile}
|
||||
>{i18n._(
|
||||
`toolbar.routing.activities.${profile}`
|
||||
@@ -195,7 +195,7 @@
|
||||
disabled={!validSelection}
|
||||
onclick={fileActions.reverseSelection}
|
||||
>
|
||||
<ArrowRightLeft size="12" />{i18n._('toolbar.routing.reverse.button')}
|
||||
<ArrowRightLeft class="size-3" />{i18n._('toolbar.routing.reverse.button')}
|
||||
</ButtonWithTooltip>
|
||||
<ButtonWithTooltip
|
||||
label={i18n._('toolbar.routing.route_back_to_start.tooltip')}
|
||||
@@ -231,7 +231,7 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Home size="12" />{i18n._('toolbar.routing.route_back_to_start.button')}
|
||||
<House class="size-3" />{i18n._('toolbar.routing.route_back_to_start.button')}
|
||||
</ButtonWithTooltip>
|
||||
<ButtonWithTooltip
|
||||
label={i18n._('toolbar.routing.round_trip.tooltip')}
|
||||
@@ -240,7 +240,7 @@
|
||||
disabled={!validSelection}
|
||||
onclick={fileActions.createRoundTripForSelection}
|
||||
>
|
||||
<Repeat size="12" />{i18n._('toolbar.routing.round_trip.button')}
|
||||
<Repeat class="size-3" />{i18n._('toolbar.routing.round_trip.button')}
|
||||
</ButtonWithTooltip>
|
||||
</div>
|
||||
<div class="w-full flex flex-row gap-2 items-end justify-between">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</script>
|
||||
|
||||
<div bind:this={element} class="hidden">
|
||||
<Card.Root class="border-none shadow-md text-base">
|
||||
<Card.Root class="border-none shadow-md text-base p-0 gap-0 rounded-lg">
|
||||
<Card.Content class="flex flex-col p-1">
|
||||
{#if $canChangeStart}
|
||||
<Button
|
||||
@@ -23,7 +23,7 @@
|
||||
variant="ghost"
|
||||
onclick={() => element?.dispatchEvent(new CustomEvent('change-start'))}
|
||||
>
|
||||
<CirclePlay size="16" class="mr-1" />
|
||||
<CirclePlay size="16" />
|
||||
{i18n._('toolbar.routing.start_loop_here')}
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -32,7 +32,7 @@
|
||||
variant="ghost"
|
||||
onclick={() => element?.dispatchEvent(new CustomEvent('delete'))}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
<Trash2 size="16" />
|
||||
{i18n._('menu.delete')}
|
||||
<Shortcut shift={true} click={true} />
|
||||
</Button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import { get } from 'svelte/store';
|
||||
|
||||
const { routing, routingProfile, privateRoads } = settings;
|
||||
|
||||
export const brouterProfiles: { [key: string]: string } = {
|
||||
export const routingProfiles: { [key: string]: string } = {
|
||||
bike: 'Trekking-dry',
|
||||
racing_bike: 'fastbike',
|
||||
gravel_bike: 'gravel',
|
||||
@@ -19,7 +19,7 @@ export const brouterProfiles: { [key: string]: string } = {
|
||||
|
||||
export function route(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||
if (get(routing)) {
|
||||
return getRoute(points, brouterProfiles[get(routingProfile)], get(privateRoads));
|
||||
return getRoute(points, routingProfiles[get(routingProfile)], get(privateRoads));
|
||||
} else {
|
||||
return getIntermediatePoints(points);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,21 @@ 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 0;
|
||||
return MIN_ANCHOR_ZOOM;
|
||||
}
|
||||
|
||||
const rad = Math.PI / 180;
|
||||
const lat = latitude * rad;
|
||||
|
||||
return Math.min(22, Math.max(0, Math.log2((earthRadius * Math.cos(lat)) / distance)));
|
||||
return Math.min(
|
||||
MAX_ANCHOR_ZOOM,
|
||||
Math.max(MIN_ANCHOR_ZOOM, Math.round(Math.log2((earthRadius * Math.cos(lat)) / distance)))
|
||||
);
|
||||
}
|
||||
|
||||
export function updateAnchorPoints(file: GPXFile) {
|
||||
|
||||
@@ -26,26 +26,24 @@
|
||||
|
||||
let validSelection = $derived(
|
||||
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
|
||||
$gpxStatistics.local.points.length > 0
|
||||
$gpxStatistics.global.length > 0
|
||||
);
|
||||
let maxSliderValue = $derived(
|
||||
validSelection && $gpxStatistics.local.points.length > 0
|
||||
? $gpxStatistics.local.points.length - 1
|
||||
: 1
|
||||
validSelection && $gpxStatistics.global.length > 0 ? $gpxStatistics.global.length - 1 : 1
|
||||
);
|
||||
let sliderValues = $derived([0, maxSliderValue]);
|
||||
let canCrop = $derived(sliderValues[0] != 0 || sliderValues[1] != maxSliderValue);
|
||||
|
||||
onMount(() => {
|
||||
if ($map) {
|
||||
splitControls = new SplitControls($map);
|
||||
splitControls = new SplitControls($map, map.layerEventManager!);
|
||||
}
|
||||
});
|
||||
|
||||
function updateSlicedGPXStatistics() {
|
||||
if (validSelection && canCrop) {
|
||||
$slicedGPXStatistics = [
|
||||
get(gpxStatistics).slice(sliderValues[0], sliderValues[1]),
|
||||
get(gpxStatistics).sliced(sliderValues[0], sliderValues[1]),
|
||||
sliderValues[0],
|
||||
sliderValues[1],
|
||||
];
|
||||
@@ -99,7 +97,7 @@
|
||||
disabled={!validSelection || !canCrop}
|
||||
onclick={() => fileActions.cropSelection(sliderValues[0], sliderValues[1])}
|
||||
>
|
||||
<Crop size="16" class="mr-1" />{i18n._('toolbar.scissors.crop')}
|
||||
<Crop size="16" />{i18n._('toolbar.scissors.crop')}
|
||||
</Button>
|
||||
<Separator />
|
||||
<Label class="flex flex-row flex-wrap gap-3 items-center">
|
||||
@@ -107,7 +105,7 @@
|
||||
{i18n._('toolbar.scissors.split_as')}
|
||||
</span>
|
||||
<Select.Root bind:value={$splitAs} type="single">
|
||||
<Select.Trigger class="h-8 w-fit grow">
|
||||
<Select.Trigger class="w-fit grow" size="sm">
|
||||
{i18n._('gpx.' + $splitAs)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { TrackPoint, TrackSegment } from 'gpx';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { ListTrackSegmentItem } from '$lib/components/file-list/file-list';
|
||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||
@@ -9,19 +7,34 @@ import { gpxStatistics } from '$lib/logic/statistics';
|
||||
import { get } from 'svelte/store';
|
||||
import { 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 {
|
||||
active: boolean = false;
|
||||
map: mapboxgl.Map;
|
||||
controls: ControlWithMarker[] = [];
|
||||
shownControls: ControlWithMarker[] = [];
|
||||
map: maplibregl.Map;
|
||||
layerEventManager: MapLayerEventManager;
|
||||
unsubscribes: Function[] = [];
|
||||
|
||||
toggleControlsForZoomLevelAndBoundsBinded: () => void =
|
||||
this.toggleControlsForZoomLevelAndBounds.bind(this);
|
||||
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
|
||||
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
|
||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map) {
|
||||
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
|
||||
this.map = map;
|
||||
this.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)));
|
||||
@@ -31,29 +44,18 @@ export class SplitControls {
|
||||
addIfNeeded() {
|
||||
let scissors = get(currentTool) === Tool.SCISSORS;
|
||||
if (!scissors) {
|
||||
if (this.active) {
|
||||
this.remove();
|
||||
}
|
||||
this.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.active) {
|
||||
this.updateControls();
|
||||
} else {
|
||||
this.add();
|
||||
}
|
||||
}
|
||||
|
||||
add() {
|
||||
this.active = true;
|
||||
|
||||
this.map.on('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
this.map.on('move', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
this.updateControls();
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
// Update the markers when the files change
|
||||
let controlIndex = 0;
|
||||
let data: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
};
|
||||
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
let file = fileStateCollection.getFile(fileId);
|
||||
|
||||
@@ -64,30 +66,23 @@ export class SplitControls {
|
||||
new ListTrackSegmentItem(fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
for (let point of segment.trkpt.slice(1, -1)) {
|
||||
// Update the existing controls (could be improved by matching the existing controls with the new ones?)
|
||||
for (let i = 1; i < segment.trkpt.length - 1; i++) {
|
||||
let point = segment.trkpt[i];
|
||||
if (point._data.anchor) {
|
||||
if (controlIndex < this.controls.length) {
|
||||
this.controls[controlIndex].fileId = fileId;
|
||||
this.controls[controlIndex].point = point;
|
||||
this.controls[controlIndex].segment = segment;
|
||||
this.controls[controlIndex].trackIndex = trackIndex;
|
||||
this.controls[controlIndex].segmentIndex = segmentIndex;
|
||||
this.controls[controlIndex].marker.setLngLat(
|
||||
point.getCoordinates()
|
||||
);
|
||||
} else {
|
||||
this.controls.push(
|
||||
this.createControl(
|
||||
point,
|
||||
segment,
|
||||
fileId,
|
||||
trackIndex,
|
||||
segmentIndex
|
||||
)
|
||||
);
|
||||
}
|
||||
controlIndex++;
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,86 +90,86 @@ export class SplitControls {
|
||||
}
|
||||
}, false);
|
||||
|
||||
while (controlIndex < this.controls.length) {
|
||||
// Remove the extra controls
|
||||
this.controls.pop()?.marker.remove();
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
this.toggleControlsForZoomLevelAndBounds();
|
||||
if (!this.map.getLayer('split-controls')) {
|
||||
this.map.addLayer(
|
||||
{
|
||||
id: 'split-controls',
|
||||
type: 'symbol',
|
||||
source: 'split-controls',
|
||||
layout: {
|
||||
'icon-image': 'split-control',
|
||||
'icon-size': 0.25,
|
||||
'icon-padding': 0,
|
||||
},
|
||||
filter: ['<=', ['get', 'minZoom'], ['zoom']],
|
||||
},
|
||||
ANCHOR_LAYER_KEY.interactions
|
||||
);
|
||||
|
||||
this.layerEventManager.on(
|
||||
'mouseenter',
|
||||
'split-controls',
|
||||
this.layerOnMouseEnterBinded
|
||||
);
|
||||
this.layerEventManager.on(
|
||||
'mouseleave',
|
||||
'split-controls',
|
||||
this.layerOnMouseLeaveBinded
|
||||
);
|
||||
this.layerEventManager.on('click', 'split-controls', this.layerOnClickBinded);
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.active = false;
|
||||
this.layerEventManager.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
|
||||
this.layerEventManager.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
|
||||
this.layerEventManager.off('click', 'split-controls', this.layerOnClickBinded);
|
||||
|
||||
for (let control of this.controls) {
|
||||
control.marker.remove();
|
||||
}
|
||||
this.map.off('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
this.map.off('move', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
}
|
||||
|
||||
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();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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"');
|
||||
layerOnMouseEnter(e: any) {
|
||||
mapCursor.notify(MapCursorState.SPLIT_CONTROL, true);
|
||||
}
|
||||
|
||||
let marker = new mapboxgl.Marker({
|
||||
draggable: true,
|
||||
className: 'z-10',
|
||||
element,
|
||||
}).setLngLat(point.getCoordinates());
|
||||
layerOnMouseLeave() {
|
||||
mapCursor.notify(MapCursorState.SPLIT_CONTROL, false);
|
||||
}
|
||||
|
||||
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;
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
@@ -182,16 +177,3 @@ 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,6 +16,8 @@
|
||||
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;
|
||||
@@ -39,6 +41,21 @@
|
||||
})
|
||||
);
|
||||
|
||||
let marker: maplibregl.Marker | null = null;
|
||||
|
||||
function reset() {
|
||||
if ($selectedWaypoint) {
|
||||
selectedWaypoint.reset();
|
||||
} else {
|
||||
name = '';
|
||||
description = '';
|
||||
link = '';
|
||||
sym = '';
|
||||
longitude = 0;
|
||||
latitude = 0;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($selectedWaypoint) {
|
||||
const wpt = $selectedWaypoint[0];
|
||||
@@ -54,14 +71,7 @@
|
||||
latitude = parseFloat(wpt.getLatitude().toFixed(6));
|
||||
});
|
||||
} else {
|
||||
untrack(() => {
|
||||
name = '';
|
||||
description = '';
|
||||
link = '';
|
||||
sym = '';
|
||||
longitude = 0;
|
||||
latitude = 0;
|
||||
});
|
||||
untrack(reset);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -85,14 +95,14 @@
|
||||
desc: description.length > 0 ? description : undefined,
|
||||
cmt: description.length > 0 ? description : undefined,
|
||||
link: link.length > 0 ? { attributes: { href: link } } : undefined,
|
||||
sym: sym,
|
||||
sym: sym.length > 0 ? sym : undefined,
|
||||
},
|
||||
selectedWaypoint.wpt && selectedWaypoint.fileId
|
||||
? new ListWaypointItem(selectedWaypoint.fileId, selectedWaypoint.wpt._data.index)
|
||||
: undefined
|
||||
);
|
||||
|
||||
selectedWaypoint.reset();
|
||||
reset();
|
||||
}
|
||||
|
||||
function setCoordinates(e: any) {
|
||||
@@ -100,6 +110,37 @@
|
||||
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);
|
||||
@@ -112,6 +153,10 @@
|
||||
$map.off('click', setCoordinates);
|
||||
mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, false);
|
||||
}
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
marker = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -129,19 +174,27 @@
|
||||
bind:value={description}
|
||||
id="description"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
class="min-h-8 h-8 py-1 px-3 text-sm"
|
||||
/>
|
||||
<Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label>
|
||||
<Select.Root bind:value={sym} type="single">
|
||||
<Select.Trigger
|
||||
id="symbol"
|
||||
class="w-full h-8"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
>
|
||||
{#if symbolKey}
|
||||
{i18n._(`gpx.symbol.${symbolKey}`)}
|
||||
{:else}
|
||||
{sym}
|
||||
{/if}
|
||||
<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}
|
||||
{i18n._(`gpx.symbol.${symbolKey}`)}
|
||||
{:else}
|
||||
{sym}
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||
{#each sortedSymbols as [key, symbol]}
|
||||
@@ -149,7 +202,7 @@
|
||||
<span>
|
||||
{#if symbol.icon}
|
||||
{@const Component = symbol.icon}
|
||||
<Component size="14" class="inline-block align-sub mr-0.5" />
|
||||
<Component size="14" class="inline-block align-sub" />
|
||||
{:else}
|
||||
<span class="w-4 inline-block"></span>
|
||||
{/if}
|
||||
@@ -203,14 +256,14 @@
|
||||
onclick={createOrUpdateWaypoint}
|
||||
>
|
||||
{#if $selectedWaypoint}
|
||||
<Save size="16" class="mr-1 shrink-0" />
|
||||
<Save size="16" class="shrink-0" />
|
||||
{i18n._('menu.metadata.save')}
|
||||
{:else}
|
||||
<MapPin size="16" class="mr-1 shrink-0" />
|
||||
<MapPin size="16" class="shrink-0" />
|
||||
{i18n._('toolbar.waypoint.create')}
|
||||
{/if}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={() => selectedWaypoint.reset()}>
|
||||
<Button variant="outline" size="icon" onclick={reset}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,14 @@
|
||||
disabled = false,
|
||||
locale,
|
||||
class: className = '',
|
||||
onchange = () => {},
|
||||
}: {
|
||||
value?: DateValue;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
locale: string;
|
||||
class?: string;
|
||||
onchange?: (date: DateValue | undefined) => void;
|
||||
} = $props();
|
||||
|
||||
const df = new DateFormatter(locale, {
|
||||
@@ -43,6 +45,6 @@
|
||||
{value ? df.format(value.toDate(getLocalTimeZone())) : placeholder}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content bind:ref={contentRef} class="w-auto p-0">
|
||||
<Calendar type="single" captionLayout="dropdown" bind:value />
|
||||
<Calendar type="single" captionLayout="dropdown" bind:value onValueChange={onchange} />
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
|
||||
export let value: string | number;
|
||||
let {
|
||||
id,
|
||||
value = $bindable(),
|
||||
disabled,
|
||||
oninput = () => {},
|
||||
onchange = () => {},
|
||||
onkeypress = () => {},
|
||||
onfocusin = () => {},
|
||||
class: className,
|
||||
}: {
|
||||
id: string;
|
||||
value: string | number;
|
||||
disabled?: boolean;
|
||||
oninput?: (e: Event) => void;
|
||||
onchange?: (e: Event) => void;
|
||||
onkeypress?: (e: KeyboardEvent) => void;
|
||||
onfocusin?: (e: FocusEvent) => void;
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
{id}
|
||||
type="text"
|
||||
step={1}
|
||||
bind:value
|
||||
on:input
|
||||
on:change
|
||||
on:keypress
|
||||
on:focusin={() => {
|
||||
{disabled}
|
||||
{oninput}
|
||||
{onchange}
|
||||
{onkeypress}
|
||||
onfocusin={(e) => {
|
||||
let input = document.activeElement;
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.select();
|
||||
}
|
||||
onfocusin(e);
|
||||
}}
|
||||
on:focusin
|
||||
class="w-[22px] {$$props.class ?? ''}"
|
||||
{...$$restProps}
|
||||
class="w-[22px] {className ?? ''}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -29,8 +48,11 @@
|
||||
|
||||
div :global(input) {
|
||||
@apply px-0.5;
|
||||
@apply py-0;
|
||||
@apply bg-transparent;
|
||||
@apply text-right;
|
||||
@apply border-none;
|
||||
@apply shadow-none;
|
||||
@apply focus:ring-0;
|
||||
@apply focus:ring-offset-0;
|
||||
@apply focus:outline-none;
|
||||
|
||||
@@ -1,160 +1,183 @@
|
||||
<script lang="ts">
|
||||
import TimeComponentInput from './TimeComponentInput.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import TimeComponentInput from './TimeComponentInput.svelte';
|
||||
|
||||
export let showHours = true;
|
||||
export let value: number | undefined = undefined;
|
||||
export let disabled: boolean = false;
|
||||
export let onChange = () => {};
|
||||
let {
|
||||
showHours = true,
|
||||
value = $bindable(),
|
||||
disabled = false,
|
||||
onChange = () => {},
|
||||
}: {
|
||||
showHours?: boolean;
|
||||
value?: number;
|
||||
disabled?: boolean;
|
||||
onChange?: () => void;
|
||||
} = $props();
|
||||
|
||||
let hours: string | number = '--';
|
||||
let minutes: string | number = '--';
|
||||
let seconds: string | number = '--';
|
||||
let hours: string | number = $state('--');
|
||||
let minutes: string | number = $state('--');
|
||||
let seconds: string | number = $state('--');
|
||||
|
||||
function maybeParseInt(value: string | number): number {
|
||||
if (value === '--' || value === '') {
|
||||
return 0;
|
||||
}
|
||||
return typeof value === 'string' ? parseInt(value) : value;
|
||||
}
|
||||
function maybeParseInt(value: string | number): number {
|
||||
if (value === '--' || value === '') {
|
||||
return 0;
|
||||
}
|
||||
return typeof value === 'string' ? parseInt(value) : value;
|
||||
}
|
||||
|
||||
function computeValue() {
|
||||
return Math.max(
|
||||
maybeParseInt(hours) * 3600 + maybeParseInt(minutes) * 60 + maybeParseInt(seconds),
|
||||
1
|
||||
);
|
||||
}
|
||||
function computeValue(): number {
|
||||
return Math.max(
|
||||
maybeParseInt(hours) * 3600 + maybeParseInt(minutes) * 60 + maybeParseInt(seconds),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
function updateValue() {
|
||||
value = computeValue();
|
||||
}
|
||||
$effect(() => {
|
||||
const val = computeValue();
|
||||
untrack(() => {
|
||||
value = val;
|
||||
});
|
||||
});
|
||||
|
||||
$: hours, minutes, seconds, updateValue();
|
||||
$effect(() => {
|
||||
if (value === undefined) {
|
||||
untrack(() => {
|
||||
hours = '--';
|
||||
minutes = '--';
|
||||
seconds = '--';
|
||||
});
|
||||
} else {
|
||||
untrack(() => {
|
||||
if (value != computeValue()) {
|
||||
let rounded = Math.max(Math.round(value!), 1);
|
||||
if (showHours) {
|
||||
hours = Math.floor(rounded / 3600);
|
||||
minutes = Math.floor((rounded % 3600) / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
} else {
|
||||
minutes = Math.floor(rounded / 60).toString();
|
||||
}
|
||||
seconds = (rounded % 60).toString().padStart(2, '0');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$: if (value === undefined) {
|
||||
hours = '--';
|
||||
minutes = '--';
|
||||
seconds = '--';
|
||||
} else if (value !== computeValue()) {
|
||||
let rounded = Math.max(Math.round(value), 1);
|
||||
if (showHours) {
|
||||
hours = Math.floor(rounded / 3600);
|
||||
minutes = Math.floor((rounded % 3600) / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
} else {
|
||||
minutes = Math.floor(rounded / 60).toString();
|
||||
}
|
||||
seconds = (rounded % 60).toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let countKeyPress = 0;
|
||||
function onKeyPress(e) {
|
||||
if (['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(e.key)) {
|
||||
countKeyPress++;
|
||||
if (countKeyPress === 2) {
|
||||
if (e.target.id === 'hours') {
|
||||
container.querySelector('#minutes')?.focus();
|
||||
} else if (e.target.id === 'minutes') {
|
||||
container.querySelector('#seconds')?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let container: HTMLDivElement;
|
||||
let countKeyPress = 0;
|
||||
function onKeyPress(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLInputElement | null;
|
||||
if (target && ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(e.key)) {
|
||||
countKeyPress++;
|
||||
if (countKeyPress === 2) {
|
||||
const nextInput =
|
||||
target.id === 'hours'
|
||||
? (container.querySelector('#minutes') as HTMLInputElement)
|
||||
: target.id === 'minutes'
|
||||
? (container.querySelector('#seconds') as HTMLInputElement)
|
||||
: null;
|
||||
if (nextInput) {
|
||||
nextInput.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="flex flex-row items-center w-full min-w-fit border rounded-md px-3 focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 {disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''}"
|
||||
bind:this={container}
|
||||
class="h-9 flex flex-row items-center w-full min-w-fit border rounded-md px-3 focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 {disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''}"
|
||||
>
|
||||
{#if showHours}
|
||||
<TimeComponentInput
|
||||
id="hours"
|
||||
bind:value={hours}
|
||||
{disabled}
|
||||
class="w-[30px]"
|
||||
on:input={() => {
|
||||
if (typeof hours === 'string') {
|
||||
hours = parseInt(hours);
|
||||
}
|
||||
if (hours >= 0) {
|
||||
} else if (hours < 0) {
|
||||
hours = 0;
|
||||
} else {
|
||||
hours = 0;
|
||||
}
|
||||
onChange();
|
||||
}}
|
||||
on:keypress={onKeyPress}
|
||||
on:focusin={() => {
|
||||
countKeyPress = 0;
|
||||
}}
|
||||
/>
|
||||
<span class="text-sm">:</span>
|
||||
{/if}
|
||||
<TimeComponentInput
|
||||
id="minutes"
|
||||
bind:value={minutes}
|
||||
{disabled}
|
||||
on:input={() => {
|
||||
if (typeof minutes === 'string') {
|
||||
minutes = parseInt(minutes);
|
||||
}
|
||||
if (minutes >= 0 && (minutes <= 59 || !showHours)) {
|
||||
} else if (minutes < 0) {
|
||||
minutes = 0;
|
||||
} else if (showHours && minutes > 59) {
|
||||
minutes = 59;
|
||||
} else {
|
||||
minutes = 0;
|
||||
}
|
||||
minutes = minutes.toString().padStart(showHours ? 2 : 1, '0');
|
||||
onChange();
|
||||
}}
|
||||
on:keypress={onKeyPress}
|
||||
on:focusin={() => {
|
||||
countKeyPress = 0;
|
||||
}}
|
||||
/>
|
||||
<span class="text-sm">:</span>
|
||||
<TimeComponentInput
|
||||
id="seconds"
|
||||
bind:value={seconds}
|
||||
{disabled}
|
||||
on:input={() => {
|
||||
if (typeof seconds === 'string') {
|
||||
seconds = parseInt(seconds);
|
||||
}
|
||||
if (seconds >= 0 && seconds <= 59) {
|
||||
} else if (seconds < 0) {
|
||||
seconds = 0;
|
||||
} else if (seconds > 59) {
|
||||
seconds = 59;
|
||||
} else {
|
||||
seconds = 0;
|
||||
}
|
||||
seconds = seconds.toString().padStart(2, '0');
|
||||
onChange();
|
||||
}}
|
||||
on:keypress={onKeyPress}
|
||||
on:focusin={() => {
|
||||
countKeyPress = 0;
|
||||
}}
|
||||
/>
|
||||
{#if showHours}
|
||||
<TimeComponentInput
|
||||
id="hours"
|
||||
bind:value={hours}
|
||||
{disabled}
|
||||
class="w-[30px]"
|
||||
oninput={() => {
|
||||
if (typeof hours === 'string') {
|
||||
hours = parseInt(hours);
|
||||
}
|
||||
if (hours >= 0) {
|
||||
} else if (hours < 0) {
|
||||
hours = 0;
|
||||
} else {
|
||||
hours = 0;
|
||||
}
|
||||
onChange();
|
||||
}}
|
||||
onkeypress={onKeyPress}
|
||||
onfocusin={() => {
|
||||
countKeyPress = 0;
|
||||
}}
|
||||
/>
|
||||
<span class="text-sm">:</span>
|
||||
{/if}
|
||||
<TimeComponentInput
|
||||
id="minutes"
|
||||
bind:value={minutes}
|
||||
{disabled}
|
||||
oninput={() => {
|
||||
if (typeof minutes === 'string') {
|
||||
minutes = parseInt(minutes);
|
||||
}
|
||||
if (minutes >= 0 && (minutes <= 59 || !showHours)) {
|
||||
} else if (minutes < 0) {
|
||||
minutes = 0;
|
||||
} else if (showHours && minutes > 59) {
|
||||
minutes = 59;
|
||||
} else {
|
||||
minutes = 0;
|
||||
}
|
||||
minutes = minutes.toString().padStart(showHours ? 2 : 1, '0');
|
||||
onChange();
|
||||
}}
|
||||
onkeypress={onKeyPress}
|
||||
onfocusin={() => {
|
||||
countKeyPress = 0;
|
||||
}}
|
||||
/>
|
||||
<span class="text-sm">:</span>
|
||||
<TimeComponentInput
|
||||
id="seconds"
|
||||
bind:value={seconds}
|
||||
{disabled}
|
||||
oninput={() => {
|
||||
if (typeof seconds === 'string') {
|
||||
seconds = parseInt(seconds);
|
||||
}
|
||||
if (seconds >= 0 && seconds <= 59) {
|
||||
} else if (seconds < 0) {
|
||||
seconds = 0;
|
||||
} else if (seconds > 59) {
|
||||
seconds = 59;
|
||||
} else {
|
||||
seconds = 0;
|
||||
}
|
||||
seconds = seconds.toString().padStart(2, '0');
|
||||
onChange();
|
||||
}}
|
||||
onkeypress={onKeyPress}
|
||||
onfocusin={() => {
|
||||
countKeyPress = 0;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div :global(input::-webkit-outer-spin-button) {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
div :global(input::-webkit-inner-spin-button) {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
div :global(input[type='number']) {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
div :global(input::-webkit-outer-spin-button) {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
div :global(input::-webkit-inner-spin-button) {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
div :global(input[type='number']) {
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { HeartHandshake } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <HeartHandshake size="18" class="mr-1 inline-block align-baseline" /> Help keep the website free (and ad-free)
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" /> Help keep the website free (and ad-free)
|
||||
|
||||
Each time you add or move GPS points, our servers calculate the best route on the road network.
|
||||
We also use APIs from <a href="https://mapbox.com" target="_blank">Mapbox</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Languages } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <Languages size="18" class="mr-1 inline-block align-baseline" /> Translation
|
||||
## <Languages size="18" class="inline-block align-baseline" /> Translation
|
||||
|
||||
The website is translated by volunteers using a collaborative translation platform.
|
||||
You can contribute by adding or improving translations on our <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin project</a>.
|
||||
|
||||
@@ -29,13 +29,13 @@ You can also drag and drop files directly from your file system into the window.
|
||||
|
||||
Create a copy of the currently selected files.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Close
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
|
||||
|
||||
Close the currently selected files.
|
||||
Delete the currently selected files.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Close all
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
|
||||
|
||||
Close all files.
|
||||
Delete all files.
|
||||
|
||||
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Export...
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Route planning and editing
|
||||
---
|
||||
|
||||
<script>
|
||||
import { Pencil, Route, Bike, TriangleAlert, ArrowRightLeft, Home, Repeat, Trash2, CirclePlay, SquareArrowUpLeft } from '@lucide/svelte';
|
||||
import { Pencil, Route, Bike, TriangleAlert, ArrowRightLeft, House, Repeat, Trash2, CirclePlay, SquareArrowUpLeft } from '@lucide/svelte';
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
|
||||
import DocsImage from '$lib/components/docs/DocsImage.svelte';
|
||||
@@ -71,7 +71,7 @@ The following tools automate some common route modification operations.
|
||||
|
||||
Reverse the direction of the route.
|
||||
|
||||
### <Home size="16" class="inline-block" style="margin-bottom: 2px" /> Back to start
|
||||
### <House size="16" class="inline-block" style="margin-bottom: 2px" /> Back to start
|
||||
|
||||
Connect the last point of the route with the starting point, using the chosen routing settings.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { HeartHandshake } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <HeartHandshake size="18" class="mr-1 inline-block align-baseline" /> Ajuda a mantenir aquesta pàgina web gratuïta (i sense anuncis)
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" /> Ajuda a mantenir aquesta pàgina web gratuïta (i sense anuncis)
|
||||
|
||||
Cada cop que afegeixes o mous un punt GPS, els nostres servidors calculen la millor ruta possible.
|
||||
També utilitzen l'API de <a href="https://mapbox.com" target="_blank">Mapbox</a> per ensenyar mapes bonics, donar informació sobre l'altitud i permetre la cerca de llocs d'interès.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Languages } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <Languages size="18" class="mr-1 inline-block align-baseline" />Traducció
|
||||
## <Languages size="18" class="inline-block align-baseline" /> Traducció
|
||||
|
||||
Aquesta pàgina web ha estat traduïda per voluntaris utilitzant una plataforma de traducció col·laborativa.
|
||||
Tu també pots contribuir-hi afegint o millorant les traduccions al nostre <a href="https://crowdin.com/project/gpxstudio" target="_blank">projecte de Crowdin</a>.
|
||||
|
||||
@@ -29,13 +29,13 @@ Pots arrossegar y deixar arxius directament des del seu sistema d'arxius cap a l
|
||||
|
||||
Crear una còpia dels arxius seleccionats.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Tanca
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
|
||||
|
||||
Tanca els arxius seleccionats.
|
||||
Delete the currently selected files.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Tanca tot
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
|
||||
|
||||
Tanca tots els arxius.
|
||||
Delete all files.
|
||||
|
||||
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Exportar...
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user