mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-02-06 16:33:09 +00:00
Compare commits
733 Commits
preserve-t
...
9ab24ed8ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab24ed8ca | ||
|
|
6cb9716d30 | ||
|
|
7e60a8fc33 | ||
|
|
d57121dc73 | ||
|
|
41b55157f5 | ||
|
|
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 | ||
|
|
1db9ecafef | ||
|
|
aa624e2c60 | ||
|
|
bde7e3e8aa | ||
|
|
b2b3e1b153 | ||
|
|
76b3d09320 | ||
|
|
899dcddd2e | ||
|
|
9edae7e1b8 | ||
|
|
8d26842aab | ||
|
|
76e654304b | ||
|
|
d73b684999 | ||
|
|
a78bd8d7ca | ||
|
|
2ca53c1004 | ||
|
|
d621516d59 | ||
|
|
ef310cc3cc | ||
|
|
776c867c0b | ||
|
|
8abe0ec333 | ||
|
|
e57ced0ce7 | ||
|
|
117c46341b | ||
|
|
ba1ac69151 | ||
|
|
a8d3af35de | ||
|
|
307eed86e3 | ||
|
|
3f103323c7 | ||
|
|
05df3ca064 | ||
|
|
356884cf58 | ||
|
|
e68da7354e | ||
|
|
c59cd66141 | ||
|
|
9fa8fe5767 | ||
|
|
4ae0bc25c2 | ||
|
|
de81a8940e | ||
|
|
a73da0d81d | ||
|
|
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 | ||
|
|
0733562c0d | ||
|
|
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 | ||
|
|
1cc07901f6 | ||
|
|
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 | ||
|
|
f0230d4634 | ||
|
|
228ad1044e | ||
|
|
a9ea0e223d | ||
|
|
37e8237f78 | ||
|
|
40deb19837 | ||
|
|
a9deb681e0 | ||
|
|
018e638ae3 | ||
|
|
967d271667 | ||
|
|
4e92a16d8d | ||
|
|
b4bcda12c2 | ||
|
|
e5bb902cd4 | ||
|
|
a3817fd5cd | ||
|
|
3a6338e9fb | ||
|
|
34d2342bdb | ||
|
|
5623a6b662 | ||
|
|
de0cc63d53 | ||
|
|
cb4892ace3 | ||
|
|
3cd17d7409 | ||
|
|
2848402e97 | ||
|
|
7e8f1acf67 | ||
|
|
8aa8a77260 | ||
|
|
ea4e078c92 | ||
|
|
21261f732f | ||
|
|
4e9d65089c | ||
|
|
e4f6dfbf78 | ||
|
|
9083784ffd | ||
|
|
6c4c4dbac9 | ||
|
|
ea0abf15ce | ||
|
|
71cccc5bdc | ||
|
|
4964928ac0 | ||
|
|
7fde60a267 | ||
|
|
306ed2ae0e | ||
|
|
a7cfe36b2e | ||
|
|
f4879a9e8a | ||
|
|
c8e09fcd90 | ||
|
|
c5f20d323c | ||
|
|
e3dcdf2f41 | ||
|
|
82d8b5d61e | ||
|
|
47692656e4 | ||
|
|
b5bf06b37a | ||
|
|
bc3b1e5f7c | ||
|
|
63eae15191 | ||
|
|
848b6dcef3 | ||
|
|
dfcdd71057 | ||
|
|
7368945bf3 | ||
|
|
de52203e89 | ||
|
|
62d1a3e01f | ||
|
|
68b4ecadf5 | ||
|
|
7831774703 | ||
|
|
52984d4b70 | ||
|
|
0b457f9a1e | ||
|
|
01cfd448f0 | ||
|
|
c189ebd8ca | ||
|
|
b35d11c9ed | ||
|
|
5fa5908072 | ||
|
|
a89f2754d3 | ||
|
|
453ae55db0 | ||
|
|
c1a5bdd7ae | ||
|
|
d19e702084 | ||
|
|
e02a22eaea | ||
|
|
63f3d63518 | ||
|
|
0d03ebfe96 | ||
|
|
074da855c1 | ||
|
|
9a028b9d5d | ||
|
|
a502980a39 | ||
|
|
1a1f6e5131 | ||
|
|
a67501e6de | ||
|
|
1f4164aca3 | ||
|
|
25e05f9855 | ||
|
|
1555799533 | ||
|
|
93d5211d27 | ||
|
|
de38fea917 | ||
|
|
3a3bc1c0db | ||
|
|
b5871d974e | ||
|
|
2985c3201b | ||
|
|
cf2fcd8221 | ||
|
|
2cb21a43c5 | ||
|
|
bae0a3f93b | ||
|
|
a853c45ec7 | ||
|
|
6cb6c88cd1 | ||
|
|
077f2b4435 | ||
|
|
8c3c4860f8 | ||
|
|
143592f724 | ||
|
|
dc404706c5 | ||
|
|
7a80e9e104 | ||
|
|
d7aae81c41 | ||
|
|
745c7e8470 | ||
|
|
52623350bd | ||
|
|
7d2c030ebd | ||
|
|
b841326e19 | ||
|
|
23c41f18de | ||
|
|
44e11e1a51 | ||
|
|
798d8e7a14 | ||
|
|
29748cf114 | ||
|
|
f5794b1355 |
8
.github/workflows/deploy.yml
vendored
8
.github/workflows/deploy.yml
vendored
@@ -8,12 +8,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: |
|
cache-dependency-path: |
|
||||||
gpx/package-lock.json
|
gpx/package-lock.json
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
npm run build --prefix website
|
npm run build --prefix website
|
||||||
|
|
||||||
- name: Upload Artifacts
|
- name: Upload Artifacts
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: 'website/build/'
|
path: 'website/build/'
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,5 @@
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
src/lib/components/ui
|
||||||
|
*.mdx
|
||||||
16
.prettierrc
Normal file
16
.prettierrc
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "**/*.svelte",
|
||||||
|
"options": {
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"svelte.svelte-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
13
.vscode/settings.json
vendored
Normal file
13
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[svelte]": {
|
||||||
|
"editor.defaultFormatter": "svelte.svelte-vscode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2024 gpx.studio
|
Copyright (c) 2025 gpx.studio
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Any help is greatly appreciated!
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
The code is split into two parts:
|
The code is split into two parts:
|
||||||
|
|
||||||
- `gpx`: a Typescript library for parsing and manipulating GPX files,
|
- `gpx`: a Typescript library for parsing and manipulating GPX files,
|
||||||
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
|
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
|
||||||
|
|
||||||
@@ -57,10 +58,9 @@ This project has been made possible thanks to the following open source projects
|
|||||||
- Development:
|
- Development:
|
||||||
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience
|
- [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
|
- [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation
|
||||||
- [svelte-i18n](https://github.com/kaisermann/svelte-i18n) — easy localization
|
|
||||||
- Design:
|
- Design:
|
||||||
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
|
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
|
||||||
- [lucide-svelte](https://github.com/lucide-icons/lucide/tree/main/packages/lucide-svelte) — beautiful icons
|
- [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons
|
||||||
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
|
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
|
||||||
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
|
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
|
||||||
- Logic:
|
- Logic:
|
||||||
|
|||||||
1617
gpx/package-lock.json
generated
1617
gpx/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,16 +12,20 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-xml-parser": "^4.5.0",
|
"fast-xml-parser": "^4.5.0",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1"
|
||||||
"ts-node": "^10.9.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/geojson": "^7946.0.14",
|
"@types/geojson": "^7946.0.14",
|
||||||
"@types/node": "^20.16.10",
|
"@types/node": "^20.16.10",
|
||||||
|
"@typescript-eslint/parser": "^8.22.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"postinstall": "npm run build"
|
"postinstall": "npm run build",
|
||||||
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"format": "prettier --write ."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1118
gpx/src/gpx.ts
1118
gpx/src/gpx.ts
File diff suppressed because it is too large
Load Diff
@@ -2,4 +2,3 @@ export * from './gpx';
|
|||||||
export { Coordinates, LineStyleExtension, WaypointType } from './types';
|
export { Coordinates, LineStyleExtension, WaypointType } from './types';
|
||||||
export { parseGPX, buildGPX } from './io';
|
export { parseGPX, buildGPX } from './io';
|
||||||
export * from './simplify';
|
export * from './simplify';
|
||||||
|
|
||||||
|
|||||||
110
gpx/src/io.ts
110
gpx/src/io.ts
@@ -1,25 +1,68 @@
|
|||||||
import { XMLParser, XMLBuilder } from "fast-xml-parser";
|
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
|
||||||
import { GPXFileType } from "./types";
|
import { GPXFileType } from './types';
|
||||||
import { GPXFile } from "./gpx";
|
import { GPXFile } from './gpx';
|
||||||
|
|
||||||
|
const attributesWithNamespace = {
|
||||||
|
RoutePointExtension: 'gpxx:RoutePointExtension',
|
||||||
|
rpt: 'gpxx:rpt',
|
||||||
|
TrackPointExtension: 'gpxtpx:TrackPointExtension',
|
||||||
|
PowerExtension: 'gpxpx:PowerExtension',
|
||||||
|
atemp: 'gpxtpx:atemp',
|
||||||
|
hr: 'gpxtpx:hr',
|
||||||
|
cad: 'gpxtpx:cad',
|
||||||
|
Extensions: 'gpxtpx:Extensions',
|
||||||
|
PowerInWatts: 'gpxpx:PowerInWatts',
|
||||||
|
power: 'gpxpx:PowerExtension',
|
||||||
|
line: 'gpx_style:line',
|
||||||
|
color: 'gpx_style:color',
|
||||||
|
opacity: 'gpx_style:opacity',
|
||||||
|
width: 'gpx_style:width',
|
||||||
|
};
|
||||||
|
|
||||||
|
const floatPatterns = [
|
||||||
|
/[-+]?\d*\.\d+$/, // decimal
|
||||||
|
/[-+]?\d+$/, // integer
|
||||||
|
];
|
||||||
|
function safeParseFloat(value: string): number {
|
||||||
|
const parsed = parseFloat(value);
|
||||||
|
if (!isNaN(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
for (const pattern of floatPatterns) {
|
||||||
|
const match = value.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
return parseFloat(match[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseGPX(gpxData: string): GPXFile {
|
export function parseGPX(gpxData: string): GPXFile {
|
||||||
const parser = new XMLParser({
|
const parser = new XMLParser({
|
||||||
ignoreAttributes: false,
|
ignoreAttributes: false,
|
||||||
attributeNamePrefix: "",
|
attributeNamePrefix: '',
|
||||||
attributesGroupName: 'attributes',
|
attributesGroupName: 'attributes',
|
||||||
|
removeNSPrefix: true,
|
||||||
isArray(name: string) {
|
isArray(name: string) {
|
||||||
return name === 'trk' || name === 'trkseg' || name === 'trkpt' || name === 'wpt' || name === 'rte' || name === 'rtept' || name === 'gpxx:rpt';
|
return (
|
||||||
|
name === 'trk' ||
|
||||||
|
name === 'trkseg' ||
|
||||||
|
name === 'trkpt' ||
|
||||||
|
name === 'wpt' ||
|
||||||
|
name === 'rte' ||
|
||||||
|
name === 'rtept' ||
|
||||||
|
name === 'gpxx:rpt'
|
||||||
|
);
|
||||||
},
|
},
|
||||||
attributeValueProcessor(attrName, attrValue, jPath) {
|
attributeValueProcessor(attrName, attrValue, jPath) {
|
||||||
if (attrName === 'lat' || attrName === 'lon') {
|
if (attrName === 'lat' || attrName === 'lon') {
|
||||||
return parseFloat(attrValue);
|
return safeParseFloat(attrValue);
|
||||||
}
|
}
|
||||||
return attrValue;
|
return attrValue;
|
||||||
},
|
},
|
||||||
transformTagName(tagName: string) {
|
transformTagName(tagName: string) {
|
||||||
if (tagName === 'power') {
|
if (attributesWithNamespace[tagName]) {
|
||||||
// Transform the simple <power> tag to the more complex <gpxpx:PowerExtension> tag, the nested <gpxpx:PowerInWatts> tag is then handled by the tagValueProcessor
|
return attributesWithNamespace[tagName];
|
||||||
return 'gpxpx:PowerExtension';
|
|
||||||
}
|
}
|
||||||
return tagName;
|
return tagName;
|
||||||
},
|
},
|
||||||
@@ -27,22 +70,29 @@ export function parseGPX(gpxData: string): GPXFile {
|
|||||||
tagValueProcessor(tagName, tagValue, jPath, hasAttributes, isLeafNode) {
|
tagValueProcessor(tagName, tagValue, jPath, hasAttributes, isLeafNode) {
|
||||||
if (isLeafNode) {
|
if (isLeafNode) {
|
||||||
if (tagName === 'ele') {
|
if (tagName === 'ele') {
|
||||||
return parseFloat(tagValue);
|
return safeParseFloat(tagValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tagName === 'time') {
|
if (tagName === 'time') {
|
||||||
return new Date(tagValue);
|
return new Date(tagValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tagName === 'gpxtpx:atemp' || tagName === 'gpxtpx:hr' || tagName === 'gpxtpx:cad' || tagName === 'gpxpx:PowerInWatts' || tagName === 'opacity' || tagName === 'weight') {
|
if (
|
||||||
return parseFloat(tagValue);
|
tagName === 'gpxtpx:atemp' ||
|
||||||
|
tagName === 'gpxtpx:hr' ||
|
||||||
|
tagName === 'gpxtpx:cad' ||
|
||||||
|
tagName === 'gpxpx:PowerInWatts' ||
|
||||||
|
tagName === 'gpx_style:opacity' ||
|
||||||
|
tagName === 'gpx_style:width'
|
||||||
|
) {
|
||||||
|
return safeParseFloat(tagValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tagName === 'gpxpx:PowerExtension') {
|
if (tagName === 'gpxpx:PowerExtension') {
|
||||||
// Finish the transformation of the simple <power> tag to the more complex <gpxpx:PowerExtension> tag
|
// Finish the transformation of the simple <power> tag to the more complex <gpxpx:PowerExtension> tag
|
||||||
// Note that this only targets the transformed <power> tag, since it must be a leaf node
|
// Note that this only targets the transformed <power> tag, since it must be a leaf node
|
||||||
return {
|
return {
|
||||||
'gpxpx:PowerInWatts': parseFloat(tagValue)
|
'gpxpx:PowerInWatts': safeParseFloat(tagValue),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,7 +104,7 @@ export function parseGPX(gpxData: string): GPXFile {
|
|||||||
const parsed: GPXFileType = parser.parse(gpxData).gpx;
|
const parsed: GPXFileType = parser.parse(gpxData).gpx;
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (parsed.metadata === "") {
|
if (parsed.metadata === '') {
|
||||||
parsed.metadata = {};
|
parsed.metadata = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,25 +114,32 @@ export function parseGPX(gpxData: string): GPXFile {
|
|||||||
export function buildGPX(file: GPXFile, exclude: string[]): string {
|
export function buildGPX(file: GPXFile, exclude: string[]): string {
|
||||||
const gpx = file.toGPXFileType(exclude);
|
const gpx = file.toGPXFileType(exclude);
|
||||||
|
|
||||||
|
let lastDate = undefined;
|
||||||
const builder = new XMLBuilder({
|
const builder = new XMLBuilder({
|
||||||
format: true,
|
format: true,
|
||||||
ignoreAttributes: false,
|
ignoreAttributes: false,
|
||||||
attributeNamePrefix: "",
|
attributeNamePrefix: '',
|
||||||
attributesGroupName: 'attributes',
|
attributesGroupName: 'attributes',
|
||||||
suppressEmptyNode: true,
|
suppressEmptyNode: true,
|
||||||
tagValueProcessor: (tagName: string, tagValue: unknown): string => {
|
tagValueProcessor: (tagName: string, tagValue: unknown): string | undefined => {
|
||||||
if (tagValue instanceof Date) {
|
if (tagValue instanceof Date) {
|
||||||
|
if (isNaN(tagValue.getTime())) {
|
||||||
|
return lastDate?.toISOString();
|
||||||
|
}
|
||||||
|
lastDate = tagValue;
|
||||||
return tagValue.toISOString();
|
return tagValue.toISOString();
|
||||||
}
|
}
|
||||||
return tagValue.toString();
|
return tagValue.toString();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
gpx.attributes.creator = gpx.attributes.creator ?? 'https://gpx.studio';
|
if (!gpx.attributes) gpx.attributes = {};
|
||||||
|
gpx.attributes['creator'] = gpx.attributes['creator'] ?? 'https://gpx.studio';
|
||||||
gpx.attributes['version'] = '1.1';
|
gpx.attributes['version'] = '1.1';
|
||||||
gpx.attributes['xmlns'] = 'http://www.topografix.com/GPX/1/1';
|
gpx.attributes['xmlns'] = 'http://www.topografix.com/GPX/1/1';
|
||||||
gpx.attributes['xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance';
|
gpx.attributes['xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance';
|
||||||
gpx.attributes['xsi:schemaLocation'] = 'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd';
|
gpx.attributes['xsi:schemaLocation'] =
|
||||||
|
'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd';
|
||||||
gpx.attributes['xmlns:gpxtpx'] = 'http://www.garmin.com/xmlschemas/TrackPointExtension/v1';
|
gpx.attributes['xmlns:gpxtpx'] = 'http://www.garmin.com/xmlschemas/TrackPointExtension/v1';
|
||||||
gpx.attributes['xmlns:gpxx'] = 'http://www.garmin.com/xmlschemas/GpxExtensions/v3';
|
gpx.attributes['xmlns:gpxx'] = 'http://www.garmin.com/xmlschemas/GpxExtensions/v3';
|
||||||
gpx.attributes['xmlns:gpxpx'] = 'http://www.garmin.com/xmlschemas/PowerExtension/v1';
|
gpx.attributes['xmlns:gpxpx'] = 'http://www.garmin.com/xmlschemas/PowerExtension/v1';
|
||||||
@@ -93,19 +150,24 @@ export function buildGPX(file: GPXFile, exclude: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return builder.build({
|
return builder.build({
|
||||||
"?xml": {
|
'?xml': {
|
||||||
attributes: {
|
attributes: {
|
||||||
version: "1.0",
|
version: '1.0',
|
||||||
encoding: "UTF-8",
|
encoding: 'UTF-8',
|
||||||
}
|
|
||||||
},
|
},
|
||||||
gpx: removeEmptyElements(gpx)
|
},
|
||||||
|
gpx: removeEmptyElements(gpx),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeEmptyElements(obj: GPXFileType): GPXFileType {
|
function removeEmptyElements(obj: GPXFileType): GPXFileType {
|
||||||
for (const key in obj) {
|
for (const key in obj) {
|
||||||
if (obj[key] === null || obj[key] === undefined || obj[key] === '' || (Array.isArray(obj[key]) && obj[key].length === 0)) {
|
if (
|
||||||
|
obj[key] === null ||
|
||||||
|
obj[key] === undefined ||
|
||||||
|
obj[key] === '' ||
|
||||||
|
(Array.isArray(obj[key]) && obj[key].length === 0)
|
||||||
|
) {
|
||||||
delete obj[key];
|
delete obj[key];
|
||||||
} else if (typeof obj[key] === 'object' && !(obj[key] instanceof Date)) {
|
} else if (typeof obj[key] === 'object' && !(obj[key] instanceof Date)) {
|
||||||
removeEmptyElements(obj[key]);
|
removeEmptyElements(obj[key]);
|
||||||
|
|||||||
@@ -1,33 +1,46 @@
|
|||||||
import { TrackPoint } from "./gpx";
|
import { TrackPoint } from './gpx';
|
||||||
import { Coordinates } from "./types";
|
import { Coordinates } from './types';
|
||||||
|
|
||||||
export type SimplifiedTrackPoint = { point: TrackPoint, distance?: number };
|
export type SimplifiedTrackPoint = { point: TrackPoint; distance?: number };
|
||||||
|
|
||||||
const earthRadius = 6371008.8;
|
export function ramerDouglasPeucker(
|
||||||
|
points: TrackPoint[],
|
||||||
export function ramerDouglasPeucker(points: TrackPoint[], epsilon: number = 50, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance): SimplifiedTrackPoint[] {
|
epsilon: number = 50,
|
||||||
|
measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance
|
||||||
|
): SimplifiedTrackPoint[] {
|
||||||
if (points.length == 0) {
|
if (points.length == 0) {
|
||||||
return [];
|
return [];
|
||||||
} else if (points.length == 1) {
|
} else if (points.length == 1) {
|
||||||
return [{
|
return [
|
||||||
point: points[0]
|
{
|
||||||
}];
|
point: points[0],
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
let simplified = [{
|
let simplified = [
|
||||||
point: points[0]
|
{
|
||||||
}];
|
point: points[0],
|
||||||
|
},
|
||||||
|
];
|
||||||
ramerDouglasPeuckerRecursive(points, epsilon, measure, 0, points.length - 1, simplified);
|
ramerDouglasPeuckerRecursive(points, epsilon, measure, 0, points.length - 1, simplified);
|
||||||
simplified.push({
|
simplified.push({
|
||||||
point: points[points.length - 1]
|
point: points[points.length - 1],
|
||||||
});
|
});
|
||||||
return simplified;
|
return simplified;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ramerDouglasPeuckerRecursive(points: TrackPoint[], epsilon: number, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number, start: number, end: number, simplified: SimplifiedTrackPoint[]) {
|
function ramerDouglasPeuckerRecursive(
|
||||||
|
points: TrackPoint[],
|
||||||
|
epsilon: number,
|
||||||
|
measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
simplified: SimplifiedTrackPoint[]
|
||||||
|
) {
|
||||||
let largest = {
|
let largest = {
|
||||||
index: 0,
|
index: 0,
|
||||||
distance: 0
|
distance: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = start + 1; i < end; i++) {
|
for (let i = start + 1; i < end; i++) {
|
||||||
@@ -45,111 +58,105 @@ function ramerDouglasPeuckerRecursive(points: TrackPoint[], epsilon: number, mea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function crossarcDistance(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint | Coordinates): number {
|
export function crossarcDistance(
|
||||||
return crossarc(point1.getCoordinates(), point2.getCoordinates(), point3 instanceof TrackPoint ? point3.getCoordinates() : point3);
|
point1: TrackPoint,
|
||||||
|
point2: TrackPoint,
|
||||||
|
point3: TrackPoint | Coordinates
|
||||||
|
): number {
|
||||||
|
return crossarc(
|
||||||
|
point1.getCoordinates(),
|
||||||
|
point2.getCoordinates(),
|
||||||
|
point3 instanceof TrackPoint ? point3.getCoordinates() : point3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metersPerLatitudeDegree = 111320;
|
||||||
|
|
||||||
|
function getMetersPerLongitudeDegree(latitude: number): number {
|
||||||
|
return Math.cos((latitude * Math.PI) / 180) * metersPerLatitudeDegree;
|
||||||
}
|
}
|
||||||
|
|
||||||
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
|
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
|
||||||
// Calculates the shortest distance in meters
|
// Calculates the perpendicular distance in meters
|
||||||
// between an arc (defined by p1 and p2) and a third point, p3.
|
// between a line segment (defined by p1 and p2) and a third point, p3.
|
||||||
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
|
// Uses simple planar geometry (ignores earth curvature).
|
||||||
|
|
||||||
const rad = Math.PI / 180;
|
// Convert to meters using approximate scaling
|
||||||
const lat1 = coord1.lat * rad;
|
const metersPerLongitudeDegree = getMetersPerLongitudeDegree(coord1.lat);
|
||||||
const lat2 = coord2.lat * rad;
|
|
||||||
const lat3 = coord3.lat * rad;
|
|
||||||
|
|
||||||
const lon1 = coord1.lon * rad;
|
const x1 = coord1.lon * metersPerLongitudeDegree;
|
||||||
const lon2 = coord2.lon * rad;
|
const y1 = coord1.lat * metersPerLatitudeDegree;
|
||||||
const lon3 = coord3.lon * rad;
|
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 dx = x2 - x1;
|
||||||
const bear12 = bearing(lat1, lon1, lat2, lon2);
|
const dy = y2 - y1;
|
||||||
const bear13 = bearing(lat1, lon1, lat3, lon3);
|
const segmentLengthSquared = dx * dx + dy * dy;
|
||||||
let dis13 = distance(lat1, lon1, lat3, lon3);
|
|
||||||
|
|
||||||
let diff = Math.abs(bear13 - bear12);
|
if (segmentLengthSquared === 0) {
|
||||||
if (diff > Math.PI) {
|
// p1 and p2 are the same point
|
||||||
diff = 2 * Math.PI - diff;
|
return Math.sqrt((x3 - x1) * (x3 - x1) + (y3 - y1) * (y3 - y1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Is relative bearing obtuse?
|
// Project p3 onto the line defined by p1-p2
|
||||||
if (diff > (Math.PI / 2)) {
|
const t = Math.max(0, Math.min(1, ((x3 - x1) * dx + (y3 - y1) * dy) / segmentLengthSquared));
|
||||||
return dis13;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the cross-track distance.
|
// Find the closest point on the segment
|
||||||
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
|
const projX = x1 + t * dx;
|
||||||
|
const projY = y1 + t * dy;
|
||||||
|
|
||||||
// Is p4 beyond the arc?
|
// Return distance from p3 to the projected point
|
||||||
let dis12 = distance(lat1, lon1, lat2, lon2);
|
return Math.sqrt((x3 - projX) * (x3 - projX) + (y3 - projY) * (y3 - projY));
|
||||||
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 {
|
export function projectedPoint(
|
||||||
// Finds the distance between two lat / lon points.
|
point1: TrackPoint,
|
||||||
return Math.acos(Math.sin(latA) * Math.sin(latB) + Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)) * earthRadius;
|
point2: TrackPoint,
|
||||||
}
|
point3: TrackPoint | Coordinates
|
||||||
|
): Coordinates {
|
||||||
|
return projected(
|
||||||
function bearing(latA: number, lonA: number, latB: number, lonB: number): number {
|
point1.getCoordinates(),
|
||||||
// Finds the bearing from one lat / lon point to another.
|
point2.getCoordinates(),
|
||||||
return Math.atan2(Math.sin(lonB - lonA) * Math.cos(latB),
|
point3 instanceof TrackPoint ? point3.getCoordinates() : point3
|
||||||
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA));
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export function projectedPoint(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint | Coordinates): Coordinates {
|
|
||||||
return projected(point1.getCoordinates(), point2.getCoordinates(), point3 instanceof TrackPoint ? point3.getCoordinates() : point3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
|
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.
|
// 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;
|
// Convert to meters using approximate scaling
|
||||||
const lat1 = coord1.lat * rad;
|
const metersPerLongitudeDegree = getMetersPerLongitudeDegree(coord1.lat);
|
||||||
const lat2 = coord2.lat * rad;
|
|
||||||
const lat3 = coord3.lat * rad;
|
|
||||||
|
|
||||||
const lon1 = coord1.lon * rad;
|
const x1 = coord1.lon * metersPerLongitudeDegree;
|
||||||
const lon2 = coord2.lon * rad;
|
const y1 = coord1.lat * metersPerLatitudeDegree;
|
||||||
const lon3 = coord3.lon * rad;
|
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 dx = x2 - x1;
|
||||||
const bear12 = bearing(lat1, lon1, lat2, lon2);
|
const dy = y2 - y1;
|
||||||
const bear13 = bearing(lat1, lon1, lat3, lon3);
|
const segmentLengthSquared = dx * dx + dy * dy;
|
||||||
let dis13 = distance(lat1, lon1, lat3, lon3);
|
|
||||||
|
|
||||||
let diff = Math.abs(bear13 - bear12);
|
if (segmentLengthSquared === 0) {
|
||||||
if (diff > Math.PI) {
|
// p1 and p2 are the same point
|
||||||
diff = 2 * Math.PI - diff;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is relative bearing obtuse?
|
|
||||||
if (diff > (Math.PI / 2)) {
|
|
||||||
return coord1;
|
return coord1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the cross-track distance.
|
// Project p3 onto the line defined by p1-p2
|
||||||
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
|
const t = Math.max(0, Math.min(1, ((x3 - x1) * dx + (y3 - y1) * dy) / segmentLengthSquared));
|
||||||
|
|
||||||
// Is p4 beyond the arc?
|
// Find the closest point on the segment
|
||||||
let dis12 = distance(lat1, lon1, lat2, lon2);
|
const projX = x1 + t * dx;
|
||||||
let dis14 = Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
const projY = y1 + t * dy;
|
||||||
if (dis14 > dis12) {
|
|
||||||
return coord2;
|
|
||||||
} else {
|
|
||||||
// Determine the closest point (p4) on the great circle
|
|
||||||
const f = dis14 / earthRadius;
|
|
||||||
const lat4 = Math.asin(Math.sin(lat1) * Math.cos(f) + Math.cos(lat1) * Math.sin(f) * Math.cos(bear12));
|
|
||||||
const lon4 = lon1 + Math.atan2(Math.sin(bear12) * Math.sin(f) * Math.cos(lat1), Math.cos(f) - Math.sin(lat1) * Math.sin(lat4));
|
|
||||||
|
|
||||||
return { lat: lat4 / rad, lon: lon4 / rad };
|
// Convert back to degrees
|
||||||
}
|
return {
|
||||||
|
lat: projY / metersPerLatitudeDegree,
|
||||||
|
lon: projX / metersPerLongitudeDegree,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -67,9 +67,9 @@ export type TrackExtensions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type LineStyleExtension = {
|
export type LineStyleExtension = {
|
||||||
color?: string;
|
'gpx_style:color'?: string;
|
||||||
opacity?: number;
|
'gpx_style:opacity'?: number;
|
||||||
weight?: number;
|
'gpx_style:width'?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TrackSegmentType = {
|
export type TrackSegmentType = {
|
||||||
@@ -93,11 +93,11 @@ export type TrackPointExtension = {
|
|||||||
'gpxtpx:hr'?: number;
|
'gpxtpx:hr'?: number;
|
||||||
'gpxtpx:cad'?: number;
|
'gpxtpx:cad'?: number;
|
||||||
'gpxtpx:Extensions'?: Record<string, string>;
|
'gpxtpx:Extensions'?: Record<string, string>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type PowerExtension = {
|
export type PowerExtension = {
|
||||||
'gpxpx:PowerInWatts'?: number;
|
'gpxpx:PowerInWatts'?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type Author = {
|
export type Author = {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -114,12 +114,12 @@ export type RouteType = {
|
|||||||
type?: string;
|
type?: string;
|
||||||
extensions?: TrackExtensions;
|
extensions?: TrackExtensions;
|
||||||
rtept: WaypointType[];
|
rtept: WaypointType[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export type RoutePointExtension = {
|
export type RoutePointExtension = {
|
||||||
'gpxx:rpt'?: GPXXRoutePoint[];
|
'gpxx:rpt'?: GPXXRoutePoint[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export type GPXXRoutePoint = {
|
export type GPXXRoutePoint = {
|
||||||
attributes: Coordinates;
|
attributes: Coordinates;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
<type>Cycling</type>
|
<type>Cycling</type>
|
||||||
<extensions>
|
<extensions>
|
||||||
<gpx_style:line>
|
<gpx_style:line>
|
||||||
<color>#2d3ee9</color>
|
<gpx_style:color>2d3ee9</gpx_style:color>
|
||||||
<opacity>0.5</opacity>
|
<gpx_style:opacity>0.5</gpx_style:opacity>
|
||||||
<weight>6</weight>
|
<gpx_style:width>6</gpx_style:width>
|
||||||
</gpx_style:line>
|
</gpx_style:line>
|
||||||
</extensions>
|
</extensions>
|
||||||
<trkseg>
|
<trkseg>
|
||||||
|
|||||||
@@ -4,9 +4,7 @@
|
|||||||
"target": "ES2015",
|
"target": "ES2015",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src"]
|
||||||
"src"
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
@@ -5,27 +5,27 @@ module.exports = {
|
|||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:svelte/recommended',
|
'plugin:svelte/recommended',
|
||||||
'prettier'
|
'prettier',
|
||||||
],
|
],
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
plugins: ['@typescript-eslint'],
|
plugins: ['@typescript-eslint'],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
extraFileExtensions: ['.svelte']
|
extraFileExtensions: ['.svelte'],
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
es2017: true,
|
es2017: true,
|
||||||
node: true
|
node: true,
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: ['*.svelte'],
|
files: ['*.svelte'],
|
||||||
parser: 'svelte-eslint-parser',
|
parser: 'svelte-eslint-parser',
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
parser: '@typescript-eslint/parser'
|
parser: '@typescript-eslint/parser',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
2
website/.gitignore
vendored
2
website/.gitignore
vendored
@@ -8,3 +8,5 @@ node_modules
|
|||||||
!.env.example
|
!.env.example
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
static/*.webmanifest
|
||||||
|
!static/en.manifest.webmanifest
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"useTabs": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "none",
|
|
||||||
"printWidth": 100,
|
|
||||||
"plugins": ["prettier-plugin-svelte"],
|
|
||||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
|
||||||
}
|
|
||||||
@@ -2,13 +2,16 @@
|
|||||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
"style": "default",
|
"style": "default",
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.js",
|
|
||||||
"css": "src/app.css",
|
"css": "src/app.css",
|
||||||
"baseColor": "slate"
|
"baseColor": "slate"
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "$lib/components",
|
"components": "$lib/components",
|
||||||
"utils": "$lib/utils"
|
"utils": "$lib/utils",
|
||||||
|
"ui": "$lib/components/ui",
|
||||||
|
"hooks": "$lib/hooks",
|
||||||
|
"lib": "$lib"
|
||||||
},
|
},
|
||||||
"typescript": true
|
"typescript": true,
|
||||||
|
"registry": "https://shadcn-svelte.com/registry"
|
||||||
}
|
}
|
||||||
6804
website/package-lock.json
generated
6804
website/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"postbuild": "npx tsx src/lib/sitemap.ts",
|
"prebuild": "npx tsx src/lib/scripts/pwa-manifest.ts",
|
||||||
|
"postbuild": "npx tsx src/lib/scripts/sitemap.ts",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
@@ -13,67 +14,71 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^3.2.5",
|
"@lucide/svelte": "^0.544.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.5",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/enhanced-img": "^0.3.8",
|
"@sveltejs/enhanced-img": "^0.6.0",
|
||||||
"@sveltejs/kit": "^2.6.1",
|
"@sveltejs/kit": "^2.21.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
"@sveltejs/vite-plugin-svelte": "^5.1.0",
|
||||||
"@types/eslint": "^8.56.12",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/events": "^3.0.3",
|
"@types/events": "^3.0.3",
|
||||||
"@types/mapbox__mapbox-gl-geocoder": "^5.0.0",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/mapbox__tilebelt": "^1.0.4",
|
"@types/mapbox__tilebelt": "^1.0.4",
|
||||||
"@types/mapbox-gl": "^3.4.0",
|
"@types/mapbox-gl": "^3.4.1",
|
||||||
"@types/node": "^20.16.10",
|
"@types/node": "^22.15.30",
|
||||||
"@types/png.js": "^0.2.3",
|
"@types/png.js": "^0.2.3",
|
||||||
"@types/sanitize-html": "^2.13.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"@types/sortablejs": "^1.15.8",
|
"@types/sortablejs": "^1.15.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
"@typescript-eslint/eslint-plugin": "^8.33.1",
|
||||||
"@typescript-eslint/parser": "^7.18.0",
|
"@typescript-eslint/parser": "^8.33.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"bits-ui": "^2.12.0",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^9.28.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^10.1.5",
|
||||||
"eslint-plugin-svelte": "^2.44.1",
|
"eslint-plugin-svelte": "^3.9.1",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"glob": "^10.4.5",
|
"glob": "^11.0.2",
|
||||||
"mdsvex": "^0.11.2",
|
"lucide-static": "^0.513.0",
|
||||||
|
"mdsvex": "^0.12.6",
|
||||||
|
"mode-watcher": "^1.1.0",
|
||||||
|
"paneforge": "^1.0.0-next.5",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-svelte": "^3.2.7",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"svelte": "^4.2.19",
|
"svelte": "^5.33.18",
|
||||||
"svelte-check": "^3.8.6",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwindcss": "^3.4.13",
|
"svelte-dnd-action": "^0.9.65",
|
||||||
"tslib": "^2.7.0",
|
"svelte-sonner": "^1.0.5",
|
||||||
|
"tailwind-variants": "^3.1.1",
|
||||||
|
"tailwindcss": "^4.1.8",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
"tsx": "^4.19.1",
|
"tsx": "^4.19.1",
|
||||||
"typescript": "^5.6.2",
|
"tw-animate-css": "^1.3.4",
|
||||||
"vite": "^5.4.8",
|
"typescript": "^5.8.3",
|
||||||
"vite-plugin-node-polyfills": "^0.22.0"
|
"vaul-svelte": "^1.0.0-next.7",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-node-polyfills": "^0.23.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docsearch/js": "^3.6.2",
|
"@docsearch/js": "^3.9.0",
|
||||||
"@internationalized/date": "^3.5.5",
|
"@internationalized/date": "^3.8.2",
|
||||||
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
|
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
|
||||||
"@mapbox/sphericalmercator": "^1.2.0",
|
"@mapbox/sphericalmercator": "^2.0.1",
|
||||||
"@mapbox/tilebelt": "^1.0.2",
|
"@mapbox/tilebelt": "^2.0.2",
|
||||||
"@types/mapbox__sphericalmercator": "^1.2.3",
|
"@types/mapbox__sphericalmercator": "^1.2.3",
|
||||||
"bits-ui": "^0.21.15",
|
"chart.js": "^4.4.9",
|
||||||
"chart.js": "^4.4.4",
|
"chartjs-plugin-zoom": "^2.2.0",
|
||||||
"chartjs-plugin-zoom": "^2.0.1",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dexie": "^4.0.8",
|
"dexie": "^4.0.11",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"gpx": "file:../gpx",
|
"gpx": "file:../gpx",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"lucide-static": "^0.427.0",
|
"jszip": "^3.10.1",
|
||||||
"lucide-svelte": "^0.427.0",
|
"mapbox-gl": "^3.16.0",
|
||||||
"mapbox-gl": "^3.7.0",
|
|
||||||
"mapillary-js": "^4.1.2",
|
"mapillary-js": "^4.1.2",
|
||||||
"mode-watcher": "^0.3.1",
|
|
||||||
"png.js": "^0.2.1",
|
"png.js": "^0.2.1",
|
||||||
"sanitize-html": "^2.13.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"sortablejs": "^1.15.3",
|
"sortablejs": "^1.15.6",
|
||||||
"svelte-i18n": "^4.0.0",
|
"tailwind-merge": "^3.3.0"
|
||||||
"svelte-sonner": "^0.3.28",
|
|
||||||
"tailwind-merge": "^2.5.2",
|
|
||||||
"tailwind-variants": "^0.2.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
126
website/src/app.css
Normal file
126
website/src/app.css
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: hsl(0 0% 100%) /* <- Wrap in HSL */;
|
||||||
|
--foreground: hsl(240 10% 3.9%);
|
||||||
|
--muted: hsl(240 4.8% 95.9%);
|
||||||
|
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||||
|
--popover: hsl(0 0% 100%);
|
||||||
|
--popover-foreground: hsl(240 10% 3.9%);
|
||||||
|
--card: hsl(0 0% 100%);
|
||||||
|
--card-foreground: hsl(240 10% 3.9%);
|
||||||
|
--border: hsl(240 5.9% 90%);
|
||||||
|
--input: hsl(240 5.9% 90%);
|
||||||
|
--primary: hsl(240 5.9% 10%);
|
||||||
|
--primary-foreground: hsl(0 0% 98%);
|
||||||
|
--secondary: hsl(240 4.8% 95.9%);
|
||||||
|
--secondary-foreground: hsl(240 5.9% 10%);
|
||||||
|
--accent: hsl(240 4.8% 95.9%);
|
||||||
|
--accent-foreground: hsl(240 5.9% 10%);
|
||||||
|
--destructive: hsl(0 72.2% 50.6%);
|
||||||
|
--destructive-foreground: hsl(0 0% 98%);
|
||||||
|
--ring: hsl(240 10% 3.9%);
|
||||||
|
--sidebar: hsl(0 0% 98%);
|
||||||
|
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||||
|
--sidebar-primary: hsl(240 5.9% 10%);
|
||||||
|
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||||
|
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||||
|
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||||
|
--sidebar-border: hsl(220 13% 91%);
|
||||||
|
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||||
|
|
||||||
|
--support: rgb(220 15 130);
|
||||||
|
--link: rgb(0 110 180);
|
||||||
|
--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%);
|
||||||
|
|
||||||
|
--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);
|
||||||
|
|
||||||
|
--breakpoint-xs: 540px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
|
<head>
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:root {
|
|
||||||
--background: 0 0% 100%;
|
|
||||||
--foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--muted: 210 40% 96.1%;
|
|
||||||
--muted-foreground: 215.4 16.3% 45%;
|
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
|
||||||
--input: 214.3 31.8% 91.4%;
|
|
||||||
|
|
||||||
--primary: 222.2 47.4% 11.2%;
|
|
||||||
--primary-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--secondary: 210 40% 96.1%;
|
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--accent: 210 40% 92%;
|
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--destructive: 0 72.2% 50.6%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--support: 220 15 130;
|
|
||||||
|
|
||||||
--link: 0 110 180;
|
|
||||||
|
|
||||||
--ring: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: 222.2 84% 4.9%;
|
|
||||||
--foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--muted: 217.2 32.6% 17.5%;
|
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
|
||||||
|
|
||||||
--popover: 222.2 84% 4.9%;
|
|
||||||
--popover-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--card: 222.2 84% 4.9%;
|
|
||||||
--card-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
|
||||||
--input: 217.2 32.6% 17.5%;
|
|
||||||
|
|
||||||
--primary: 210 40% 98%;
|
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
|
||||||
--secondary-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--accent: 217.2 32.6% 30%;
|
|
||||||
--accent-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--support: 255 110 190;
|
|
||||||
|
|
||||||
--link: 80 190 255;
|
|
||||||
|
|
||||||
--ring: hsl(212.7,26.8%,83.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,14 @@ export async function handle({ event, resolve }) {
|
|||||||
|
|
||||||
let headTag = `<head>
|
let headTag = `<head>
|
||||||
<title>gpx.studio — ${title}</title>
|
<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 name="description" content="${description}" />
|
||||||
<meta property="og:title" content="gpx.studio — ${title}" />
|
<meta property="og:title" content="gpx.studio — ${title}" />
|
||||||
<meta property="og:description" content="${description}" />
|
<meta property="og:description" content="${description}" />
|
||||||
@@ -38,15 +46,19 @@ export async function handle({ event, resolve }) {
|
|||||||
<meta name="twitter:url" content="https://gpx.studio/" />
|
<meta name="twitter:url" content="https://gpx.studio/" />
|
||||||
<meta name="twitter:site" content="@gpxstudio" />
|
<meta name="twitter:site" content="@gpxstudio" />
|
||||||
<meta name="twitter:creator" content="@gpxstudio" />
|
<meta name="twitter:creator" content="@gpxstudio" />
|
||||||
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />`;
|
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />
|
||||||
|
<link rel="manifest" href="/${language}.manifest.webmanifest" />`;
|
||||||
|
|
||||||
|
if (page !== '404') {
|
||||||
for (let lang of Object.keys(languages)) {
|
for (let lang of Object.keys(languages)) {
|
||||||
headTag += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${getURLForLanguage(lang, path)}" />
|
headTag += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${getURLForLanguage(lang, path)}" />
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const response = await resolve(event, {
|
const response = await resolve(event, {
|
||||||
transformPageChunk: ({ html }) => html.replace('<html>', htmlTag).replace('<head>', headTag),
|
transformPageChunk: ({ html }) =>
|
||||||
|
html.replace('<html>', htmlTag).replace('<head>', headTag),
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
export const surfaceColors: { [key: string]: string } = {
|
export const surfaceColors: { [key: string]: string } = {
|
||||||
"missing": "#d1d1d1",
|
missing: '#d1d1d1',
|
||||||
"paved": "#8c8c8c",
|
paved: '#8c8c8c',
|
||||||
"unpaved": "#6b443a",
|
unpaved: '#6b443a',
|
||||||
"asphalt": "#8c8c8c",
|
asphalt: '#8c8c8c',
|
||||||
"concrete": "#8c8c8c",
|
concrete: '#8c8c8c',
|
||||||
"cobblestone": "#ffd991",
|
cobblestone: '#ffd991',
|
||||||
"paving_stones": "#8c8c8c",
|
paving_stones: '#8c8c8c',
|
||||||
"sett": "#ffd991",
|
sett: '#ffd991',
|
||||||
"metal": "#8c8c8c",
|
metal: '#8c8c8c',
|
||||||
"wood": "#6b443a",
|
wood: '#6b443a',
|
||||||
"compacted": "#ffffa8",
|
compacted: '#ffffa8',
|
||||||
"fine_gravel": "#ffffa8",
|
fine_gravel: '#ffffa8',
|
||||||
"gravel": "#ffffa8",
|
gravel: '#ffffa8',
|
||||||
"pebblestone": "#ffffa8",
|
pebblestone: '#ffffa8',
|
||||||
"rock": "#ffd991",
|
rock: '#ffd991',
|
||||||
"dirt": "#ffffa8",
|
dirt: '#ffffa8',
|
||||||
"ground": "#6b443a",
|
ground: '#6b443a',
|
||||||
"earth": "#6b443a",
|
earth: '#6b443a',
|
||||||
"mud": "#6b443a",
|
mud: '#6b443a',
|
||||||
"sand": "#ffffc4",
|
sand: '#ffffc4',
|
||||||
"grass": "#61b55c",
|
grass: '#61b55c',
|
||||||
"grass_paver": "#61b55c",
|
grass_paver: '#61b55c',
|
||||||
"clay": "#6b443a",
|
clay: '#6b443a',
|
||||||
"stone": "#ffd991",
|
stone: '#ffd991',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getSurfaceColor(surface: string): string {
|
export function getSurfaceColor(surface: string): string {
|
||||||
@@ -30,66 +30,72 @@ export function getSurfaceColor(surface: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const highwayColors: { [key: string]: string } = {
|
export const highwayColors: { [key: string]: string } = {
|
||||||
"missing": "#d1d1d1",
|
missing: '#d1d1d1',
|
||||||
"motorway": "#ff4d33",
|
motorway: '#ff4d33',
|
||||||
"motorway_link": "#ff4d33",
|
motorway_link: '#ff4d33',
|
||||||
"trunk": "#ff5e4d",
|
trunk: '#ff5e4d',
|
||||||
"trunk_link": "#ff947f",
|
trunk_link: '#ff947f',
|
||||||
"primary": "#ff6e5c",
|
primary: '#ff6e5c',
|
||||||
"primary_link": "#ff6e5c",
|
primary_link: '#ff6e5c',
|
||||||
"secondary": "#ff8d7b",
|
secondary: '#ff8d7b',
|
||||||
"secondary_link": "#ff8d7b",
|
secondary_link: '#ff8d7b',
|
||||||
"tertiary": "#ffd75f",
|
tertiary: '#ffd75f',
|
||||||
"tertiary_link": "#ffd75f",
|
tertiary_link: '#ffd75f',
|
||||||
"unclassified": "#f1f2a5",
|
unclassified: '#f1f2a5',
|
||||||
"road": "#f1f2a5",
|
road: '#f1f2a5',
|
||||||
"residential": "#73b2ff",
|
residential: '#73b2ff',
|
||||||
"living_street": "#73b2ff",
|
living_street: '#73b2ff',
|
||||||
"service": "#9c9cd9",
|
service: '#9c9cd9',
|
||||||
"track": "#a8e381",
|
track: '#a8e381',
|
||||||
"footway": "#a8e381",
|
footway: '#a8e381',
|
||||||
"path": "#a8e381",
|
path: '#a8e381',
|
||||||
"pedestrian": "#a8e381",
|
pedestrian: '#a8e381',
|
||||||
"cycleway": "#9de2ff",
|
cycleway: '#9de2ff',
|
||||||
"construction": "#e09a4a",
|
construction: '#e09a4a',
|
||||||
"bridleway": "#946f43",
|
bridleway: '#946f43',
|
||||||
"raceway": "#ff0000",
|
raceway: '#ff0000',
|
||||||
"rest_area": "#9c9cd9",
|
rest_area: '#9c9cd9',
|
||||||
"services": "#9c9cd9",
|
services: '#9c9cd9',
|
||||||
"corridor": "#474747",
|
corridor: '#474747',
|
||||||
"elevator": "#474747",
|
elevator: '#474747',
|
||||||
"steps": "#474747",
|
steps: '#474747',
|
||||||
"bus_stop": "#8545a3",
|
bus_stop: '#8545a3',
|
||||||
"busway": "#8545a3",
|
busway: '#8545a3',
|
||||||
"via_ferrata": "#474747"
|
via_ferrata: '#474747',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sacScaleColors: { [key: string]: string } = {
|
export const sacScaleColors: { [key: string]: string } = {
|
||||||
"hiking": "#007700",
|
hiking: '#007700',
|
||||||
"mountain_hiking": "#1843ad",
|
mountain_hiking: '#1843ad',
|
||||||
"demanding_mountain_hiking": "#ffff00",
|
demanding_mountain_hiking: '#ffff00',
|
||||||
"alpine_hiking": "#ff9233",
|
alpine_hiking: '#ff9233',
|
||||||
"demanding_alpine_hiking": "#ff0000",
|
demanding_alpine_hiking: '#ff0000',
|
||||||
"difficult_alpine_hiking": "#000000",
|
difficult_alpine_hiking: '#000000',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mtbScaleColors: { [key: string]: string } = {
|
export const mtbScaleColors: { [key: string]: string } = {
|
||||||
"0-": "#007700",
|
'0-': '#007700',
|
||||||
"0": "#007700",
|
'0': '#007700',
|
||||||
"0+": "#007700",
|
'0+': '#007700',
|
||||||
"1-": "#1843ad",
|
'1-': '#1843ad',
|
||||||
"1": "#1843ad",
|
'1': '#1843ad',
|
||||||
"1+": "#1843ad",
|
'1+': '#1843ad',
|
||||||
"2-": "#ffff00",
|
'2-': '#ffff00',
|
||||||
"2": "#ffff00",
|
'2': '#ffff00',
|
||||||
"2+": "#ffff00",
|
'2+': '#ffff00',
|
||||||
"3": "#ff0000",
|
'3': '#ff0000',
|
||||||
"4": "#00ff00",
|
'4': '#00ff00',
|
||||||
"5": "#000000",
|
'5': '#000000',
|
||||||
"6": "#b105eb",
|
'6': '#b105eb',
|
||||||
};
|
};
|
||||||
|
|
||||||
function createPattern(backgroundColor: string, sacScaleColor: string | undefined, mtbScaleColor: string | undefined, size: number = 16, lineWidth: number = 4) {
|
function createPattern(
|
||||||
|
backgroundColor: string,
|
||||||
|
sacScaleColor: string | undefined,
|
||||||
|
mtbScaleColor: string | undefined,
|
||||||
|
size: number = 16,
|
||||||
|
lineWidth: number = 4
|
||||||
|
) {
|
||||||
let canvas = document.createElement('canvas');
|
let canvas = document.createElement('canvas');
|
||||||
canvas.width = size;
|
canvas.width = size;
|
||||||
canvas.height = size;
|
canvas.height = size;
|
||||||
@@ -104,11 +110,11 @@ function createPattern(backgroundColor: string, sacScaleColor: string | undefine
|
|||||||
if (sacScaleColor) {
|
if (sacScaleColor) {
|
||||||
ctx.strokeStyle = sacScaleColor;
|
ctx.strokeStyle = sacScaleColor;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(halfSize - halfLineWidth, - halfLineWidth);
|
ctx.moveTo(halfSize - halfLineWidth, -halfLineWidth);
|
||||||
ctx.lineTo(size + halfLineWidth, halfSize + halfLineWidth);
|
ctx.lineTo(size + halfLineWidth, halfSize + halfLineWidth);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(- halfLineWidth, halfSize - halfLineWidth);
|
ctx.moveTo(-halfLineWidth, halfSize - halfLineWidth);
|
||||||
ctx.lineTo(halfSize + halfLineWidth, size + halfLineWidth);
|
ctx.lineTo(halfSize + halfLineWidth, size + halfLineWidth);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
@@ -119,8 +125,8 @@ function createPattern(backgroundColor: string, sacScaleColor: string | undefine
|
|||||||
ctx.lineTo(size + halfLineWidth, halfSize - halfLineWidth);
|
ctx.lineTo(size + halfLineWidth, halfSize - halfLineWidth);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(- halfLineWidth, halfSize + halfLineWidth);
|
ctx.moveTo(-halfLineWidth, halfSize + halfLineWidth);
|
||||||
ctx.lineTo(halfSize + halfLineWidth, - halfLineWidth);
|
ctx.lineTo(halfSize + halfLineWidth, -halfLineWidth);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,12 +134,16 @@ function createPattern(backgroundColor: string, sacScaleColor: string | undefine
|
|||||||
}
|
}
|
||||||
|
|
||||||
const patterns: Record<string, string | CanvasPattern> = {};
|
const patterns: Record<string, string | CanvasPattern> = {};
|
||||||
export function getHighwayColor(highway: string, sacScale: string | undefined, mtbScale: string | undefined) {
|
export function getHighwayColor(
|
||||||
|
highway: string,
|
||||||
|
sacScale: string | undefined,
|
||||||
|
mtbScale: string | undefined
|
||||||
|
) {
|
||||||
let backgroundColor = highwayColors[highway] ? highwayColors[highway] : highwayColors.missing;
|
let backgroundColor = highwayColors[highway] ? highwayColors[highway] : highwayColors.missing;
|
||||||
let sacScaleColor = sacScale ? sacScaleColors[sacScale] : undefined;
|
let sacScaleColor = sacScale ? sacScaleColors[sacScale] : undefined;
|
||||||
let mtbScaleColor = mtbScale ? mtbScaleColors[mtbScale] : undefined;
|
let mtbScaleColor = mtbScale ? mtbScaleColors[mtbScale] : undefined;
|
||||||
if (sacScale || mtbScale) {
|
if (sacScale || mtbScale) {
|
||||||
let patternId = `${backgroundColor}-${[sacScale, mtbScale].filter(x => x).join('-')}`;
|
let patternId = `${backgroundColor}-${[sacScale, mtbScale].filter((x) => x).join('-')}`;
|
||||||
if (!patterns[patternId]) {
|
if (!patterns[patternId]) {
|
||||||
patterns[patternId] = createPattern(backgroundColor, sacScaleColor, mtbScaleColor);
|
patterns[patternId] = createPattern(backgroundColor, sacScaleColor, mtbScaleColor);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,73 @@
|
|||||||
import { Landmark, Icon, Shell, Bike, Building, Tent, Car, Wrench, ShoppingBasket, Droplet, DoorOpen, Trees, Fuel, Home, Info, TreeDeciduous, CircleParking, Cross, Utensils, Construction, BrickWall, ShowerHead, Mountain, Phone, TrainFront, Bed, Binoculars, TriangleAlert, Anchor } from "lucide-svelte";
|
import {
|
||||||
import { Landmark as LandmarkSvg, Shell as ShellSvg, Bike as BikeSvg, Building as BuildingSvg, Tent as TentSvg, Car as CarSvg, Wrench as WrenchSvg, ShoppingBasket as ShoppingBasketSvg, Droplet as DropletSvg, DoorOpen as DoorOpenSvg, Trees as TreesSvg, Fuel as FuelSvg, Home as HomeSvg, Info as InfoSvg, TreeDeciduous as TreeDeciduousSvg, CircleParking as CircleParkingSvg, Cross as CrossSvg, Utensils as UtensilsSvg, Construction as ConstructionSvg, BrickWall as BrickWallSvg, ShowerHead as ShowerHeadSvg, Mountain as MountainSvg, Phone as PhoneSvg, TrainFront as TrainFrontSvg, Bed as BedSvg, Binoculars as BinocularsSvg, TriangleAlert as TriangleAlertSvg, Anchor as AnchorSvg } from "lucide-static";
|
Landmark,
|
||||||
import type { ComponentType } from "svelte";
|
Shell,
|
||||||
|
Bike,
|
||||||
|
Building,
|
||||||
|
Tent,
|
||||||
|
Car,
|
||||||
|
Wrench,
|
||||||
|
ShoppingBasket,
|
||||||
|
Droplet,
|
||||||
|
DoorOpen,
|
||||||
|
Trees,
|
||||||
|
Fuel,
|
||||||
|
House,
|
||||||
|
Info,
|
||||||
|
TreeDeciduous,
|
||||||
|
CircleParking,
|
||||||
|
Cross,
|
||||||
|
Utensils,
|
||||||
|
Construction,
|
||||||
|
BrickWall,
|
||||||
|
ShowerHead,
|
||||||
|
Mountain,
|
||||||
|
Phone,
|
||||||
|
TrainFront,
|
||||||
|
Bed,
|
||||||
|
Binoculars,
|
||||||
|
TriangleAlert,
|
||||||
|
Anchor,
|
||||||
|
Toilet,
|
||||||
|
X,
|
||||||
|
type IconProps,
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import {
|
||||||
|
Landmark as LandmarkSvg,
|
||||||
|
Shell as ShellSvg,
|
||||||
|
Bike as BikeSvg,
|
||||||
|
Building as BuildingSvg,
|
||||||
|
Tent as TentSvg,
|
||||||
|
Car as CarSvg,
|
||||||
|
Wrench as WrenchSvg,
|
||||||
|
ShoppingBasket as ShoppingBasketSvg,
|
||||||
|
Droplet as DropletSvg,
|
||||||
|
DoorOpen as DoorOpenSvg,
|
||||||
|
Trees as TreesSvg,
|
||||||
|
Fuel as FuelSvg,
|
||||||
|
House as HouseSvg,
|
||||||
|
Info as InfoSvg,
|
||||||
|
TreeDeciduous as TreeDeciduousSvg,
|
||||||
|
CircleParking as CircleParkingSvg,
|
||||||
|
Cross as CrossSvg,
|
||||||
|
Utensils as UtensilsSvg,
|
||||||
|
Construction as ConstructionSvg,
|
||||||
|
BrickWall as BrickWallSvg,
|
||||||
|
ShowerHead as ShowerHeadSvg,
|
||||||
|
Mountain as MountainSvg,
|
||||||
|
Phone as PhoneSvg,
|
||||||
|
TrainFront as TrainFrontSvg,
|
||||||
|
Bed as BedSvg,
|
||||||
|
Binoculars as BinocularsSvg,
|
||||||
|
TriangleAlert as TriangleAlertSvg,
|
||||||
|
Anchor as AnchorSvg,
|
||||||
|
Toilet as ToiletSvg,
|
||||||
|
X as XSvg,
|
||||||
|
} from 'lucide-static';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
export type Symbol = {
|
export type Symbol = {
|
||||||
value: string;
|
value: string;
|
||||||
icon?: ComponentType<Icon>;
|
icon?: Component<IconProps>;
|
||||||
iconSvg?: string;
|
iconSvg?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,18 +83,34 @@ export const symbols: { [key: string]: Symbol } = {
|
|||||||
campground: { value: 'Campground', icon: Tent, iconSvg: TentSvg },
|
campground: { value: 'Campground', icon: Tent, iconSvg: TentSvg },
|
||||||
car: { value: 'Car', icon: Car, iconSvg: CarSvg },
|
car: { value: 'Car', icon: Car, iconSvg: CarSvg },
|
||||||
car_repair: { value: 'Car Repair', icon: Wrench, iconSvg: WrenchSvg },
|
car_repair: { value: 'Car Repair', icon: Wrench, iconSvg: WrenchSvg },
|
||||||
convenience_store: { value: 'Convenience Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
|
convenience_store: {
|
||||||
crossing: { value: 'Crossing' },
|
value: 'Convenience Store',
|
||||||
department_store: { value: 'Department Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
|
icon: ShoppingBasket,
|
||||||
|
iconSvg: ShoppingBasketSvg,
|
||||||
|
},
|
||||||
|
crossing: {
|
||||||
|
value: 'Crossing',
|
||||||
|
icon: X,
|
||||||
|
iconSvg: XSvg,
|
||||||
|
},
|
||||||
|
department_store: {
|
||||||
|
value: 'Department Store',
|
||||||
|
icon: ShoppingBasket,
|
||||||
|
iconSvg: ShoppingBasketSvg,
|
||||||
|
},
|
||||||
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
|
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
|
||||||
exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg },
|
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 },
|
lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg },
|
||||||
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
|
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
|
||||||
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
|
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
|
||||||
ground_transportation: { value: 'Ground Transportation', icon: TrainFront, iconSvg: TrainFrontSvg },
|
ground_transportation: {
|
||||||
|
value: 'Ground Transportation',
|
||||||
|
icon: TrainFront,
|
||||||
|
iconSvg: TrainFrontSvg,
|
||||||
|
},
|
||||||
hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg },
|
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 },
|
information: { value: 'Information', icon: Info, iconSvg: InfoSvg },
|
||||||
park: { value: 'Park', icon: TreeDeciduous, iconSvg: TreeDeciduousSvg },
|
park: { value: 'Park', icon: TreeDeciduous, iconSvg: TreeDeciduousSvg },
|
||||||
parking_area: { value: 'Parking Area', icon: CircleParking, iconSvg: CircleParkingSvg },
|
parking_area: { value: 'Parking Area', icon: CircleParking, iconSvg: CircleParkingSvg },
|
||||||
@@ -39,7 +118,7 @@ export const symbols: { [key: string]: Symbol } = {
|
|||||||
picnic_area: { value: 'Picnic Area', icon: Utensils, iconSvg: UtensilsSvg },
|
picnic_area: { value: 'Picnic Area', icon: Utensils, iconSvg: UtensilsSvg },
|
||||||
restaurant: { value: 'Restaurant', icon: Utensils, iconSvg: UtensilsSvg },
|
restaurant: { value: 'Restaurant', icon: Utensils, iconSvg: UtensilsSvg },
|
||||||
restricted_area: { value: 'Restricted Area', icon: Construction, iconSvg: ConstructionSvg },
|
restricted_area: { value: 'Restricted Area', icon: Construction, iconSvg: ConstructionSvg },
|
||||||
restroom: { value: 'Restroom' },
|
restroom: { value: 'Restroom', icon: Toilet, iconSvg: ToiletSvg },
|
||||||
road: { value: 'Road', icon: BrickWall, iconSvg: BrickWallSvg },
|
road: { value: 'Road', icon: BrickWall, iconSvg: BrickWallSvg },
|
||||||
scenic_area: { value: 'Scenic Area', icon: Binoculars, iconSvg: BinocularsSvg },
|
scenic_area: { value: 'Scenic Area', icon: Binoculars, iconSvg: BinocularsSvg },
|
||||||
shelter: { value: 'Shelter', icon: Tent, iconSvg: TentSvg },
|
shelter: { value: 'Shelter', icon: Tent, iconSvg: TentSvg },
|
||||||
@@ -55,6 +134,6 @@ export function getSymbolKey(value: string | undefined): string | undefined {
|
|||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
} else {
|
} else {
|
||||||
return Object.keys(symbols).find(key => symbols[key].value === value);
|
return Object.keys(symbols).find((key) => symbols[key].value === value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,11 @@
|
|||||||
import docsearch from '@docsearch/js';
|
import docsearch from '@docsearch/js';
|
||||||
import '@docsearch/css';
|
import '@docsearch/css';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { _, locale, waitLocale } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
|
let props: {
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
|
||||||
@@ -13,34 +17,34 @@
|
|||||||
indexName: 'gpx',
|
indexName: 'gpx',
|
||||||
container: '#docsearch',
|
container: '#docsearch',
|
||||||
searchParameters: {
|
searchParameters: {
|
||||||
facetFilters: ['lang:' + ($locale ?? 'en')]
|
facetFilters: ['lang:' + i18n.lang],
|
||||||
},
|
},
|
||||||
placeholder: $_('docs.search.search'),
|
placeholder: i18n._('docs.search.search'),
|
||||||
disableUserPersonalization: true,
|
disableUserPersonalization: true,
|
||||||
translations: {
|
translations: {
|
||||||
button: {
|
button: {
|
||||||
buttonText: $_('docs.search.search'),
|
buttonText: i18n._('docs.search.search'),
|
||||||
buttonAriaLabel: $_('docs.search.search')
|
buttonAriaLabel: i18n._('docs.search.search'),
|
||||||
},
|
},
|
||||||
modal: {
|
modal: {
|
||||||
searchBox: {
|
searchBox: {
|
||||||
resetButtonTitle: $_('docs.search.clear'),
|
resetButtonTitle: i18n._('docs.search.clear'),
|
||||||
resetButtonAriaLabel: $_('docs.search.clear'),
|
resetButtonAriaLabel: i18n._('docs.search.clear'),
|
||||||
cancelButtonText: $_('docs.search.cancel'),
|
cancelButtonText: i18n._('docs.search.cancel'),
|
||||||
cancelButtonAriaLabel: $_('docs.search.cancel'),
|
cancelButtonAriaLabel: i18n._('docs.search.cancel'),
|
||||||
searchInputLabel: $_('docs.search.search')
|
searchInputLabel: i18n._('docs.search.search'),
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
selectText: $_('docs.search.to_select'),
|
selectText: i18n._('docs.search.to_select'),
|
||||||
navigateText: $_('docs.search.to_navigate'),
|
navigateText: i18n._('docs.search.to_navigate'),
|
||||||
closeText: $_('docs.search.to_close')
|
closeText: i18n._('docs.search.to_close'),
|
||||||
},
|
},
|
||||||
noResultsScreen: {
|
noResultsScreen: {
|
||||||
noResultsText: $_('docs.search.no_results'),
|
noResultsText: i18n._('docs.search.no_results'),
|
||||||
suggestedQueryText: $_('docs.search.no_results_suggestion')
|
suggestedQueryText: i18n._('docs.search.no_results_suggestion'),
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,13 +52,15 @@
|
|||||||
mounted = true;
|
mounted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
$: if (mounted && $locale) {
|
$effect(() => {
|
||||||
waitLocale().then(initDocsearch);
|
if (mounted && i18n.lang && !i18n.isLoading) {
|
||||||
|
initDocsearch();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
|
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div id="docsearch" {...$$restProps}></div>
|
<div id="docsearch" class={props.class ?? ''}></div>
|
||||||
|
|||||||
@@ -1,28 +1,38 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
import type { Builder } from 'bits-ui';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
export let variant:
|
const {
|
||||||
| 'default'
|
variant = 'default',
|
||||||
| 'secondary'
|
label,
|
||||||
| 'link'
|
side = 'top',
|
||||||
| 'destructive'
|
disabled = false,
|
||||||
| 'outline'
|
class: className = '',
|
||||||
| 'ghost'
|
children,
|
||||||
| undefined = 'default';
|
onclick,
|
||||||
export let label: string;
|
}: {
|
||||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
variant?: 'default' | 'secondary' | 'link' | 'destructive' | 'outline' | 'ghost';
|
||||||
export let builders: Builder[] = [];
|
label: string;
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
disabled?: boolean;
|
||||||
|
class?: string;
|
||||||
|
children: Snippet;
|
||||||
|
onclick?: (event: MouseEvent) => void;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Tooltip.Root>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Trigger asChild let:builder>
|
<Tooltip.Root>
|
||||||
<Button builders={[...builders, builder]} {variant} {...$$restProps} on:click>
|
<Tooltip.Trigger>
|
||||||
<slot />
|
{#snippet child({ props })}
|
||||||
|
<Button {...props} {variant} class={className} {onclick}>
|
||||||
|
{@render children()}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/snippet}
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content {side}>
|
<Tooltip.Content {side}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
|||||||
@@ -1,671 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
|
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
|
||||||
import * as ToggleGroup from '$lib/components/ui/toggle-group';
|
|
||||||
import Chart from 'chart.js/auto';
|
|
||||||
import mapboxgl from 'mapbox-gl';
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
import {
|
|
||||||
BrickWall,
|
|
||||||
TriangleRight,
|
|
||||||
HeartPulse,
|
|
||||||
Orbit,
|
|
||||||
SquareActivity,
|
|
||||||
Thermometer,
|
|
||||||
Zap,
|
|
||||||
Circle,
|
|
||||||
Check,
|
|
||||||
ChartNoAxesColumn,
|
|
||||||
Construction
|
|
||||||
} from 'lucide-svelte';
|
|
||||||
import { getSlopeColor, getSurfaceColor, getHighwayColor } from '$lib/assets/colors';
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
import {
|
|
||||||
getCadenceWithUnits,
|
|
||||||
getConvertedDistance,
|
|
||||||
getConvertedElevation,
|
|
||||||
getConvertedTemperature,
|
|
||||||
getConvertedVelocity,
|
|
||||||
getDistanceUnits,
|
|
||||||
getDistanceWithUnits,
|
|
||||||
getElevationWithUnits,
|
|
||||||
getHeartRateWithUnits,
|
|
||||||
getPowerWithUnits,
|
|
||||||
getTemperatureWithUnits,
|
|
||||||
getVelocityWithUnits
|
|
||||||
} from '$lib/units';
|
|
||||||
import type { Writable } from 'svelte/store';
|
|
||||||
import type { GPXStatistics } from 'gpx';
|
|
||||||
import { settings } from '$lib/db';
|
|
||||||
import { mode } from 'mode-watcher';
|
|
||||||
import { df } from '$lib/utils';
|
|
||||||
|
|
||||||
export let gpxStatistics: Writable<GPXStatistics>;
|
|
||||||
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
|
||||||
export let additionalDatasets: string[];
|
|
||||||
export let elevationFill: 'slope' | 'surface' | 'highway' | undefined;
|
|
||||||
export let showControls: boolean = true;
|
|
||||||
|
|
||||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement;
|
|
||||||
let overlay: HTMLCanvasElement;
|
|
||||||
let chart: Chart;
|
|
||||||
|
|
||||||
Chart.defaults.font.family =
|
|
||||||
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
|
|
||||||
|
|
||||||
let marker: mapboxgl.Marker | null = null;
|
|
||||||
let dragging = false;
|
|
||||||
let panning = false;
|
|
||||||
|
|
||||||
let options = {
|
|
||||||
animation: false,
|
|
||||||
parsing: false,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
type: 'linear',
|
|
||||||
ticks: {
|
|
||||||
callback: function (value: number) {
|
|
||||||
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
|
|
||||||
},
|
|
||||||
align: 'inner',
|
|
||||||
maxRotation: 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
type: 'linear',
|
|
||||||
ticks: {
|
|
||||||
callback: function (value: number) {
|
|
||||||
return getElevationWithUnits(value, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
datasets: {
|
|
||||||
line: {
|
|
||||||
pointRadius: 0,
|
|
||||||
tension: 0.4,
|
|
||||||
borderWidth: 2,
|
|
||||||
cubicInterpolationMode: 'monotone'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
interaction: {
|
|
||||||
mode: 'nearest',
|
|
||||||
axis: 'x',
|
|
||||||
intersect: false
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false
|
|
||||||
},
|
|
||||||
decimation: {
|
|
||||||
enabled: true
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
enabled: () => !dragging && !panning,
|
|
||||||
callbacks: {
|
|
||||||
title: function () {
|
|
||||||
return '';
|
|
||||||
},
|
|
||||||
label: function (context: Chart.TooltipContext) {
|
|
||||||
let point = context.raw;
|
|
||||||
if (context.datasetIndex === 0) {
|
|
||||||
if ($map && marker) {
|
|
||||||
if (dragging) {
|
|
||||||
marker.remove();
|
|
||||||
} else {
|
|
||||||
marker.setLngLat(point.coordinates);
|
|
||||||
marker.addTo($map);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `${$_('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
|
||||||
} else if (context.datasetIndex === 1) {
|
|
||||||
return `${$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
|
|
||||||
} else if (context.datasetIndex === 2) {
|
|
||||||
return `${$_('quantities.heartrate')}: ${getHeartRateWithUnits(point.y)}`;
|
|
||||||
} else if (context.datasetIndex === 3) {
|
|
||||||
return `${$_('quantities.cadence')}: ${getCadenceWithUnits(point.y)}`;
|
|
||||||
} else if (context.datasetIndex === 4) {
|
|
||||||
return `${$_('quantities.temperature')}: ${getTemperatureWithUnits(point.y, false)}`;
|
|
||||||
} else if (context.datasetIndex === 5) {
|
|
||||||
return `${$_('quantities.power')}: ${getPowerWithUnits(point.y)}`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
afterBody: function (contexts: Chart.TooltipContext[]) {
|
|
||||||
let context = contexts.filter((context) => context.datasetIndex === 0);
|
|
||||||
if (context.length === 0) return;
|
|
||||||
let point = context[0].raw;
|
|
||||||
let slope = {
|
|
||||||
at: point.slope.at.toFixed(1),
|
|
||||||
segment: point.slope.segment.toFixed(1),
|
|
||||||
length: getDistanceWithUnits(point.slope.length)
|
|
||||||
};
|
|
||||||
let surface = point.extensions.surface ? point.extensions.surface : 'unknown';
|
|
||||||
let highway = point.extensions.highway ? point.extensions.highway : 'unknown';
|
|
||||||
let sacScale = point.extensions.sac_scale;
|
|
||||||
let mtbScale = point.extensions.mtb_scale;
|
|
||||||
|
|
||||||
let labels = [
|
|
||||||
` ${$_('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
|
|
||||||
` ${$_('quantities.slope')}: ${slope.at} %${elevationFill === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`
|
|
||||||
];
|
|
||||||
|
|
||||||
if (elevationFill === 'surface') {
|
|
||||||
labels.push(
|
|
||||||
` ${$_('quantities.surface')}: ${$_(`toolbar.routing.surface.${surface}`)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elevationFill === 'highway') {
|
|
||||||
labels.push(
|
|
||||||
` ${$_('quantities.highway')}: ${$_(`toolbar.routing.highway.${highway}`)}${
|
|
||||||
sacScale ? ` (${$_(`toolbar.routing.sac_scale.${sacScale}`)})` : ''
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
if (mtbScale) {
|
|
||||||
labels.push(` ${$_('toolbar.routing.mtb_scale')}: ${mtbScale}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (point.time) {
|
|
||||||
labels.push(` ${$_('quantities.time')}: ${df.format(point.time)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return labels;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
zoom: {
|
|
||||||
pan: {
|
|
||||||
enabled: true,
|
|
||||||
mode: 'x',
|
|
||||||
modifierKey: 'shift',
|
|
||||||
onPanStart: function () {
|
|
||||||
// hide tooltip
|
|
||||||
panning = true;
|
|
||||||
$slicedGPXStatistics = undefined;
|
|
||||||
},
|
|
||||||
onPanComplete: function () {
|
|
||||||
panning = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
zoom: {
|
|
||||||
wheel: {
|
|
||||||
enabled: true
|
|
||||||
},
|
|
||||||
mode: 'x',
|
|
||||||
onZoomStart: function ({ chart, event }: { chart: Chart; event: any }) {
|
|
||||||
if (
|
|
||||||
event.deltaY < 0 &&
|
|
||||||
Math.abs(
|
|
||||||
chart.getInitialScaleBounds().x.max / chart.options.plugins.zoom.limits.x.minRange -
|
|
||||||
chart.getZoomLevel()
|
|
||||||
) < 0.01
|
|
||||||
) {
|
|
||||||
// Disable wheel pan if zoomed in to the max, and zooming in
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$slicedGPXStatistics = undefined;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
limits: {
|
|
||||||
x: {
|
|
||||||
min: 'original',
|
|
||||||
max: 'original',
|
|
||||||
minRange: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
stacked: false,
|
|
||||||
onResize: function () {
|
|
||||||
updateOverlay();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
|
|
||||||
datasets.forEach((id) => {
|
|
||||||
options.scales[`y${id}`] = {
|
|
||||||
type: 'linear',
|
|
||||||
position: 'right',
|
|
||||||
grid: {
|
|
||||||
display: false
|
|
||||||
},
|
|
||||||
reverse: () => id === 'speed' && $velocityUnits === 'pace',
|
|
||||||
display: false
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
Chart.register((await import('chartjs-plugin-zoom')).default); // dynamic import to avoid SSR and 'window is not defined' error
|
|
||||||
|
|
||||||
chart = new Chart(canvas, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
datasets: []
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
id: 'toggleMarker',
|
|
||||||
events: ['mouseout'],
|
|
||||||
afterEvent: function (chart: Chart, args: { event: Chart.ChartEvent }) {
|
|
||||||
if (args.event.type === 'mouseout') {
|
|
||||||
if ($map && marker) {
|
|
||||||
marker.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Map marker to show on hover
|
|
||||||
let element = document.createElement('div');
|
|
||||||
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
|
|
||||||
marker = new mapboxgl.Marker({
|
|
||||||
element
|
|
||||||
});
|
|
||||||
|
|
||||||
let startIndex = 0;
|
|
||||||
let endIndex = 0;
|
|
||||||
function getIndex(evt) {
|
|
||||||
const points = chart.getElementsAtEventForMode(
|
|
||||||
evt,
|
|
||||||
'x',
|
|
||||||
{
|
|
||||||
intersect: false
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
if (points.length === 0) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
if (evt.x - rect.left <= chart.chartArea.left) {
|
|
||||||
return 0;
|
|
||||||
} else if (evt.x - rect.left >= chart.chartArea.right) {
|
|
||||||
return $gpxStatistics.local.points.length - 1;
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let point = points.find((point) => point.element.raw);
|
|
||||||
if (point) {
|
|
||||||
return point.element.raw.index;
|
|
||||||
} else {
|
|
||||||
return points[0].index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let dragStarted = false;
|
|
||||||
function onMouseDown(evt) {
|
|
||||||
if (evt.shiftKey) {
|
|
||||||
// Panning interaction
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dragStarted = true;
|
|
||||||
canvas.style.cursor = 'col-resize';
|
|
||||||
startIndex = getIndex(evt);
|
|
||||||
}
|
|
||||||
function onMouseMove(evt) {
|
|
||||||
if (dragStarted) {
|
|
||||||
dragging = true;
|
|
||||||
endIndex = getIndex(evt);
|
|
||||||
if (endIndex !== undefined) {
|
|
||||||
if (startIndex === undefined) {
|
|
||||||
startIndex = endIndex;
|
|
||||||
} else if (startIndex !== endIndex) {
|
|
||||||
$slicedGPXStatistics = [
|
|
||||||
$gpxStatistics.slice(Math.min(startIndex, endIndex), Math.max(startIndex, endIndex)),
|
|
||||||
Math.min(startIndex, endIndex),
|
|
||||||
Math.max(startIndex, endIndex)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function onMouseUp(evt) {
|
|
||||||
dragStarted = false;
|
|
||||||
dragging = false;
|
|
||||||
canvas.style.cursor = '';
|
|
||||||
endIndex = getIndex(evt);
|
|
||||||
if (startIndex === endIndex) {
|
|
||||||
$slicedGPXStatistics = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
canvas.addEventListener('pointerdown', onMouseDown);
|
|
||||||
canvas.addEventListener('pointermove', onMouseMove);
|
|
||||||
canvas.addEventListener('pointerup', onMouseUp);
|
|
||||||
});
|
|
||||||
|
|
||||||
$: if (chart && $distanceUnits && $velocityUnits && $temperatureUnits) {
|
|
||||||
let data = $gpxStatistics;
|
|
||||||
|
|
||||||
// update data
|
|
||||||
chart.data.datasets[0] = {
|
|
||||||
label: $_('quantities.elevation'),
|
|
||||||
data: data.local.points.map((point, index) => {
|
|
||||||
return {
|
|
||||||
x: getConvertedDistance(data.local.distance.total[index]),
|
|
||||||
y: point.ele ? getConvertedElevation(point.ele) : 0,
|
|
||||||
time: point.time,
|
|
||||||
slope: {
|
|
||||||
at: data.local.slope.at[index],
|
|
||||||
segment: data.local.slope.segment[index],
|
|
||||||
length: data.local.slope.length[index]
|
|
||||||
},
|
|
||||||
extensions: point.getExtensions(),
|
|
||||||
coordinates: point.getCoordinates(),
|
|
||||||
index: index
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
normalized: true,
|
|
||||||
fill: 'start',
|
|
||||||
order: 1
|
|
||||||
};
|
|
||||||
chart.data.datasets[1] = {
|
|
||||||
data: data.local.points.map((point, index) => {
|
|
||||||
return {
|
|
||||||
x: getConvertedDistance(data.local.distance.total[index]),
|
|
||||||
y: getConvertedVelocity(data.local.speed[index]),
|
|
||||||
index: index
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
normalized: true,
|
|
||||||
yAxisID: 'yspeed',
|
|
||||||
hidden: true
|
|
||||||
};
|
|
||||||
chart.data.datasets[2] = {
|
|
||||||
data: data.local.points.map((point, index) => {
|
|
||||||
return {
|
|
||||||
x: getConvertedDistance(data.local.distance.total[index]),
|
|
||||||
y: point.getHeartRate(),
|
|
||||||
index: index
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
normalized: true,
|
|
||||||
yAxisID: 'yhr',
|
|
||||||
hidden: true
|
|
||||||
};
|
|
||||||
chart.data.datasets[3] = {
|
|
||||||
data: data.local.points.map((point, index) => {
|
|
||||||
return {
|
|
||||||
x: getConvertedDistance(data.local.distance.total[index]),
|
|
||||||
y: point.getCadence(),
|
|
||||||
index: index
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
normalized: true,
|
|
||||||
yAxisID: 'ycad',
|
|
||||||
hidden: true
|
|
||||||
};
|
|
||||||
chart.data.datasets[4] = {
|
|
||||||
data: data.local.points.map((point, index) => {
|
|
||||||
return {
|
|
||||||
x: getConvertedDistance(data.local.distance.total[index]),
|
|
||||||
y: getConvertedTemperature(point.getTemperature()),
|
|
||||||
index: index
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
normalized: true,
|
|
||||||
yAxisID: 'yatemp',
|
|
||||||
hidden: true
|
|
||||||
};
|
|
||||||
chart.data.datasets[5] = {
|
|
||||||
data: data.local.points.map((point, index) => {
|
|
||||||
return {
|
|
||||||
x: getConvertedDistance(data.local.distance.total[index]),
|
|
||||||
y: point.getPower(),
|
|
||||||
index: index
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
normalized: true,
|
|
||||||
yAxisID: 'ypower',
|
|
||||||
hidden: true
|
|
||||||
};
|
|
||||||
chart.options.scales.x['min'] = 0;
|
|
||||||
chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
|
|
||||||
|
|
||||||
chart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
function slopeFillCallback(context) {
|
|
||||||
return getSlopeColor(context.p0.raw.slope.segment);
|
|
||||||
}
|
|
||||||
|
|
||||||
function surfaceFillCallback(context) {
|
|
||||||
return getSurfaceColor(context.p0.raw.extensions.surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
function highwayFillCallback(context) {
|
|
||||||
return getHighwayColor(
|
|
||||||
context.p0.raw.extensions.highway,
|
|
||||||
context.p0.raw.extensions.sac_scale,
|
|
||||||
context.p0.raw.extensions.mtb_scale
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (chart) {
|
|
||||||
if (elevationFill === 'slope') {
|
|
||||||
chart.data.datasets[0]['segment'] = {
|
|
||||||
backgroundColor: slopeFillCallback
|
|
||||||
};
|
|
||||||
} else if (elevationFill === 'surface') {
|
|
||||||
chart.data.datasets[0]['segment'] = {
|
|
||||||
backgroundColor: surfaceFillCallback
|
|
||||||
};
|
|
||||||
} else if (elevationFill === 'highway') {
|
|
||||||
chart.data.datasets[0]['segment'] = {
|
|
||||||
backgroundColor: highwayFillCallback
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
chart.data.datasets[0]['segment'] = {};
|
|
||||||
}
|
|
||||||
chart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (additionalDatasets && chart) {
|
|
||||||
let includeSpeed = additionalDatasets.includes('speed');
|
|
||||||
let includeHeartRate = additionalDatasets.includes('hr');
|
|
||||||
let includeCadence = additionalDatasets.includes('cad');
|
|
||||||
let includeTemperature = additionalDatasets.includes('atemp');
|
|
||||||
let includePower = additionalDatasets.includes('power');
|
|
||||||
if (chart.data.datasets.length > 0) {
|
|
||||||
chart.data.datasets[1].hidden = !includeSpeed;
|
|
||||||
chart.data.datasets[2].hidden = !includeHeartRate;
|
|
||||||
chart.data.datasets[3].hidden = !includeCadence;
|
|
||||||
chart.data.datasets[4].hidden = !includeTemperature;
|
|
||||||
chart.data.datasets[5].hidden = !includePower;
|
|
||||||
}
|
|
||||||
chart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateOverlay() {
|
|
||||||
if (!canvas) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
overlay.width = canvas.width / window.devicePixelRatio;
|
|
||||||
overlay.height = canvas.height / window.devicePixelRatio;
|
|
||||||
overlay.style.width = `${overlay.width}px`;
|
|
||||||
overlay.style.height = `${overlay.height}px`;
|
|
||||||
|
|
||||||
if ($slicedGPXStatistics) {
|
|
||||||
let startIndex = $slicedGPXStatistics[1];
|
|
||||||
let endIndex = $slicedGPXStatistics[2];
|
|
||||||
|
|
||||||
// Draw selection rectangle
|
|
||||||
let selectionContext = overlay.getContext('2d');
|
|
||||||
if (selectionContext) {
|
|
||||||
selectionContext.fillStyle = $mode === 'dark' ? 'white' : 'black';
|
|
||||||
selectionContext.globalAlpha = $mode === 'dark' ? 0.2 : 0.1;
|
|
||||||
selectionContext.clearRect(0, 0, overlay.width, overlay.height);
|
|
||||||
|
|
||||||
let startPixel = chart.scales.x.getPixelForValue(
|
|
||||||
getConvertedDistance($gpxStatistics.local.distance.total[startIndex])
|
|
||||||
);
|
|
||||||
let endPixel = chart.scales.x.getPixelForValue(
|
|
||||||
getConvertedDistance($gpxStatistics.local.distance.total[endIndex])
|
|
||||||
);
|
|
||||||
|
|
||||||
selectionContext.fillRect(
|
|
||||||
startPixel,
|
|
||||||
chart.chartArea.top,
|
|
||||||
endPixel - startPixel,
|
|
||||||
chart.chartArea.height
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (overlay) {
|
|
||||||
let selectionContext = overlay.getContext('2d');
|
|
||||||
if (selectionContext) {
|
|
||||||
selectionContext.clearRect(0, 0, overlay.width, overlay.height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: $slicedGPXStatistics, $mode, updateOverlay();
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (chart) {
|
|
||||||
chart.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="h-full grow min-w-0 relative py-2">
|
|
||||||
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas>
|
|
||||||
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas>
|
|
||||||
{#if showControls}
|
|
||||||
<div class="absolute bottom-10 right-1.5">
|
|
||||||
<Popover.Root>
|
|
||||||
<Popover.Trigger asChild let:builder>
|
|
||||||
<ButtonWithTooltip
|
|
||||||
label={$_('chart.settings')}
|
|
||||||
builders={[builder]}
|
|
||||||
variant="outline"
|
|
||||||
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 hover:bg-background"
|
|
||||||
>
|
|
||||||
<ChartNoAxesColumn size="18" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Content class="w-fit p-0 flex flex-col divide-y" side="top" sideOffset={-32}>
|
|
||||||
<ToggleGroup.Root
|
|
||||||
class="flex flex-col items-start gap-0 p-1"
|
|
||||||
type="single"
|
|
||||||
bind:value={elevationFill}
|
|
||||||
>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="slope"
|
|
||||||
>
|
|
||||||
<div class="w-6 flex justify-center items-center">
|
|
||||||
{#if elevationFill === 'slope'}
|
|
||||||
<Circle class="h-1.5 w-1.5 fill-current text-current" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<TriangleRight size="15" class="mr-1" />
|
|
||||||
{$_('quantities.slope')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="surface"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<div class="w-6 flex justify-center items-center">
|
|
||||||
{#if elevationFill === 'surface'}
|
|
||||||
<Circle class="h-1.5 w-1.5 fill-current text-current" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<BrickWall size="15" class="mr-1" />
|
|
||||||
{$_('quantities.surface')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="highway"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<div class="w-6 flex justify-center items-center">
|
|
||||||
{#if elevationFill === 'highway'}
|
|
||||||
<Circle class="h-1.5 w-1.5 fill-current text-current" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<Construction size="15" class="mr-1" />
|
|
||||||
{$_('quantities.highway')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
</ToggleGroup.Root>
|
|
||||||
<ToggleGroup.Root
|
|
||||||
class="flex flex-col items-start gap-0 p-1"
|
|
||||||
type="multiple"
|
|
||||||
bind:value={additionalDatasets}
|
|
||||||
>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="speed"
|
|
||||||
>
|
|
||||||
<div class="w-6 flex justify-center items-center">
|
|
||||||
{#if additionalDatasets.includes('speed')}
|
|
||||||
<Check size="14" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<Zap size="15" class="mr-1" />
|
|
||||||
{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="hr"
|
|
||||||
>
|
|
||||||
<div class="w-6 flex justify-center items-center">
|
|
||||||
{#if additionalDatasets.includes('hr')}
|
|
||||||
<Check size="14" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<HeartPulse size="15" class="mr-1" />
|
|
||||||
{$_('quantities.heartrate')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="cad"
|
|
||||||
>
|
|
||||||
<div class="w-6 flex justify-center items-center">
|
|
||||||
{#if additionalDatasets.includes('cad')}
|
|
||||||
<Check size="14" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<Orbit size="15" class="mr-1" />
|
|
||||||
{$_('quantities.cadence')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="atemp"
|
|
||||||
>
|
|
||||||
<div class="w-6 flex justify-center items-center">
|
|
||||||
{#if additionalDatasets.includes('atemp')}
|
|
||||||
<Check size="14" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<Thermometer size="15" class="mr-1" />
|
|
||||||
{$_('quantities.temperature')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="power"
|
|
||||||
>
|
|
||||||
<div class="w-6 flex justify-center items-center">
|
|
||||||
{#if additionalDatasets.includes('power')}
|
|
||||||
<Check size="14" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<SquareActivity size="15" class="mr-1" />
|
|
||||||
{$_('quantities.power')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
</ToggleGroup.Root>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
|
||||||
import { Dialog } from 'bits-ui';
|
|
||||||
import {
|
|
||||||
currentTool,
|
|
||||||
exportAllFiles,
|
|
||||||
exportSelectedFiles,
|
|
||||||
ExportState,
|
|
||||||
exportState,
|
|
||||||
gpxStatistics
|
|
||||||
} from '$lib/stores';
|
|
||||||
import { fileObservers } from '$lib/db';
|
|
||||||
import {
|
|
||||||
Download,
|
|
||||||
Zap,
|
|
||||||
Earth,
|
|
||||||
HeartPulse,
|
|
||||||
Orbit,
|
|
||||||
Thermometer,
|
|
||||||
SquareActivity
|
|
||||||
} from 'lucide-svelte';
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
import { selection } from './file-list/Selection';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { GPXStatistics } from 'gpx';
|
|
||||||
import { ListRootItem } from './file-list/FileList';
|
|
||||||
|
|
||||||
let open = false;
|
|
||||||
let exportOptions: Record<string, boolean> = {
|
|
||||||
time: true,
|
|
||||||
hr: true,
|
|
||||||
cad: true,
|
|
||||||
atemp: true,
|
|
||||||
power: true,
|
|
||||||
extensions: true
|
|
||||||
};
|
|
||||||
let hide: Record<string, boolean> = {
|
|
||||||
time: false,
|
|
||||||
hr: false,
|
|
||||||
cad: false,
|
|
||||||
atemp: false,
|
|
||||||
power: false,
|
|
||||||
extensions: false
|
|
||||||
};
|
|
||||||
|
|
||||||
$: if ($exportState !== ExportState.NONE) {
|
|
||||||
open = true;
|
|
||||||
$currentTool = null;
|
|
||||||
|
|
||||||
let statistics = $gpxStatistics;
|
|
||||||
if ($exportState === ExportState.ALL) {
|
|
||||||
statistics = Array.from($fileObservers.values())
|
|
||||||
.map((file) => get(file)?.statistics)
|
|
||||||
.reduce((acc, cur) => {
|
|
||||||
if (cur !== undefined) {
|
|
||||||
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, new GPXStatistics());
|
|
||||||
}
|
|
||||||
|
|
||||||
hide.time = statistics.global.time.total === 0;
|
|
||||||
hide.hr = statistics.global.hr.count === 0;
|
|
||||||
hide.cad = statistics.global.cad.count === 0;
|
|
||||||
hide.atemp = statistics.global.atemp.count === 0;
|
|
||||||
hide.power = statistics.global.power.count === 0;
|
|
||||||
hide.extensions = Object.keys(statistics.global.extensions).length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Dialog.Root
|
|
||||||
bind:open
|
|
||||||
onOpenChange={(isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
$exportState = ExportState.NONE;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Dialog.Trigger class="hidden" />
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Content
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<span>⚠️</span>
|
|
||||||
<span class="max-w-[80%] text-sm">
|
|
||||||
{$_('menu.support_message')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full flex flex-row flex-wrap gap-2">
|
|
||||||
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
|
|
||||||
{$_('menu.support_button')}
|
|
||||||
<span class="ml-2">🙏</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
class="grow"
|
|
||||||
on:click={() => {
|
|
||||||
if ($exportState === ExportState.SELECTION) {
|
|
||||||
exportSelectedFiles(exclude);
|
|
||||||
} else if ($exportState === ExportState.ALL) {
|
|
||||||
exportAllFiles(exclude);
|
|
||||||
}
|
|
||||||
open = false;
|
|
||||||
$exportState = ExportState.NONE;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Download size="16" class="mr-1" />
|
|
||||||
{#if $fileObservers.size === 1 || ($exportState === ExportState.SELECTION && $selection.size === 1)}
|
|
||||||
{$_('menu.download_file')}
|
|
||||||
{:else}
|
|
||||||
{$_('menu.download_files')}
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="w-full max-w-xl flex flex-col items-center gap-2 {Object.values(hide).some((v) => !v)
|
|
||||||
? ''
|
|
||||||
: 'hidden'}"
|
|
||||||
>
|
|
||||||
<div class="w-full flex flex-row items-center gap-3">
|
|
||||||
<div class="grow">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
<Label class="shrink-0">
|
|
||||||
{$_('menu.export_options')}
|
|
||||||
</Label>
|
|
||||||
<div class="grow">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row flex-wrap justify-center gap-x-6 gap-y-2">
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.time ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-time" bind:checked={exportOptions.time} />
|
|
||||||
<Label for="export-time" class="flex flex-row items-center gap-1">
|
|
||||||
<Zap size="16" />
|
|
||||||
{$_('quantities.time')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.extensions ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
|
|
||||||
<Label for="export-extensions" class="flex flex-row items-center gap-1">
|
|
||||||
<Earth size="16" />
|
|
||||||
{$_('quantities.osm_extensions')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
|
|
||||||
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
|
|
||||||
<HeartPulse size="16" />
|
|
||||||
{$_('quantities.heartrate')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
|
|
||||||
<Label for="export-cadence" class="flex flex-row items-center gap-1">
|
|
||||||
<Orbit size="16" />
|
|
||||||
{$_('quantities.cadence')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
|
|
||||||
<Label for="export-temperature" class="flex flex-row items-center gap-1">
|
|
||||||
<Thermometer size="16" />
|
|
||||||
{$_('quantities.temperature')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-power" bind:checked={exportOptions.power} />
|
|
||||||
<Label for="export-power" class="flex flex-row items-center gap-1">
|
|
||||||
<SquareActivity size="16" />
|
|
||||||
{$_('quantities.power')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
|
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
|
||||||
import Logo from '$lib/components/Logo.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 { _, locale } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -14,109 +14,101 @@
|
|||||||
<Logo class="h-8" width="153" />
|
<Logo class="h-8" width="153" />
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
MIT © 2024 gpx.studio
|
MIT © 2025 gpx.studio
|
||||||
</Button>
|
</Button>
|
||||||
<LanguageSelect class="w-40 mt-3" />
|
<LanguageSelect class="w-40 mt-3" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
||||||
<div class="flex flex-col items-start gap-1">
|
<div class="flex flex-col items-start gap-1">
|
||||||
<span class="font-semibold">{$_('homepage.website')}</span>
|
<span class="font-semibold">{i18n._('homepage.website')}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
href={getURLForLanguage($locale, '/')}
|
href={getURLForLanguage(i18n.lang, '/')}
|
||||||
>
|
>
|
||||||
<Home size="16" class="mr-1" />
|
<House size="16" />
|
||||||
{$_('homepage.home')}
|
{i18n._('homepage.home')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-sveltekit-reload
|
||||||
|
variant="link"
|
||||||
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
|
href={getURLForLanguage(i18n.lang, '/app')}
|
||||||
|
>
|
||||||
|
<Map size="16" />
|
||||||
|
{i18n._('homepage.app')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
href={getURLForLanguage($locale, '/app')}
|
href={getURLForLanguage(i18n.lang, '/help')}
|
||||||
>
|
>
|
||||||
<Map size="16" class="mr-1" />
|
<BookOpenText size="16" />
|
||||||
{$_('homepage.app')}
|
{i18n._('menu.help')}
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
class="h-6 px-0 text-muted-foreground"
|
|
||||||
href={getURLForLanguage($locale, '/help')}
|
|
||||||
>
|
|
||||||
<BookOpenText size="16" class="mr-1" />
|
|
||||||
{$_('menu.help')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-start gap-1" id="contact">
|
<div class="flex flex-col items-start gap-1" id="contact">
|
||||||
<span class="font-semibold">{$_('homepage.contact')}</span>
|
<span class="font-semibold">{i18n._('homepage.contact')}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
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/"
|
href="https://www.reddit.com/r/gpxstudio/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="reddit" class="h-4 mr-1 fill-muted-foreground" />
|
<Logo company="reddit" class="h-4 fill-muted-foreground" />
|
||||||
{$_('homepage.reddit')}
|
{i18n._('homepage.reddit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
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"
|
href="https://facebook.com/gpx.studio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="facebook" class="h-4 mr-1 fill-muted-foreground" />
|
<Logo company="facebook" class="h-4 fill-muted-foreground" />
|
||||||
{$_('homepage.facebook')}
|
{i18n._('homepage.facebook')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
href="https://x.com/gpxstudio"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Logo company="x" class="h-4 mr-1 fill-muted-foreground" />
|
|
||||||
{$_('homepage.x')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
class="h-6 px-0 text-muted-foreground"
|
|
||||||
href="mailto:hello@gpx.studio"
|
href="mailto:hello@gpx.studio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<AtSign size="16" class="mr-1" />
|
<AtSign size="16" />
|
||||||
{$_('homepage.email')}
|
{i18n._('homepage.email')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-start gap-1">
|
<div class="flex flex-col items-start gap-1">
|
||||||
<span class="font-semibold">{$_('homepage.contribute')}</span>
|
<span class="font-semibold">{i18n._('homepage.contribute')}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
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"
|
href="https://ko-fi.com/gpxstudio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Heart size="16" class="mr-1" />
|
<Heart size="16" />
|
||||||
{$_('menu.donate')}
|
{i18n._('menu.donate')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
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"
|
href="https://crowdin.com/project/gpxstudio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="crowdin" class="h-4 mr-1 fill-muted-foreground" />
|
<Logo company="crowdin" class="h-4 fill-muted-foreground" />
|
||||||
{$_('homepage.crowdin')}
|
{i18n._('homepage.crowdin')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
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"
|
href="https://github.com/gpxstudio/gpx.studio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="github" class="h-4 mr-1 fill-muted-foreground" />
|
<Logo company="github" class="h-4 fill-muted-foreground" />
|
||||||
{$_('homepage.github')}
|
{i18n._('homepage.github')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,46 +3,49 @@
|
|||||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||||
|
|
||||||
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from 'lucide-svelte';
|
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
|
||||||
|
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import type { GPXStatistics } from 'gpx';
|
import type { GPXStatistics } from 'gpx';
|
||||||
import type { Writable } from 'svelte/store';
|
import type { Readable } from 'svelte/store';
|
||||||
import { settings } from '$lib/db';
|
import { settings } from '$lib/logic/settings';
|
||||||
|
|
||||||
export let gpxStatistics: Writable<GPXStatistics>;
|
|
||||||
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
|
||||||
export let orientation: 'horizontal' | 'vertical';
|
|
||||||
export let panelSize: number;
|
|
||||||
|
|
||||||
const { velocityUnits } = settings;
|
const { velocityUnits } = settings;
|
||||||
|
|
||||||
let statistics: GPXStatistics;
|
let {
|
||||||
|
gpxStatistics,
|
||||||
|
slicedGPXStatistics,
|
||||||
|
orientation,
|
||||||
|
panelSize,
|
||||||
|
}: {
|
||||||
|
gpxStatistics: Readable<GPXStatistics>;
|
||||||
|
slicedGPXStatistics: Readable<[GPXStatistics, number, number] | undefined>;
|
||||||
|
orientation: 'horizontal' | 'vertical';
|
||||||
|
panelSize: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
$: if ($slicedGPXStatistics !== undefined) {
|
let statistics = $derived(
|
||||||
statistics = $slicedGPXStatistics[0];
|
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics
|
||||||
} else {
|
);
|
||||||
statistics = $gpxStatistics;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root
|
<Card.Root
|
||||||
class="h-full {orientation === 'vertical'
|
class="h-full {orientation === 'vertical'
|
||||||
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
|
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
|
||||||
: 'w-full'} border-none shadow-none"
|
: 'w-full'} border-none shadow-none p-0"
|
||||||
>
|
>
|
||||||
<Card.Content
|
<Card.Content
|
||||||
class="h-full flex {orientation === 'vertical'
|
class="h-full flex {orientation === 'vertical'
|
||||||
? 'flex-col justify-center'
|
? 'flex-col justify-center'
|
||||||
: 'flex-row w-full justify-between'} gap-4 p-0"
|
: 'flex-row w-full justify-between'} gap-4 p-0"
|
||||||
>
|
>
|
||||||
<Tooltip label={$_('quantities.distance')}>
|
<Tooltip label={i18n._('quantities.distance')}>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
<Ruler size="16" class="mr-1" />
|
<Ruler size="16" class="mr-1" />
|
||||||
<WithUnits value={statistics.global.distance.total} type="distance" />
|
<WithUnits value={statistics.global.distance.total} type="distance" />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={$_('quantities.elevation_gain_loss')}>
|
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
<MoveUpRight size="16" class="mr-1" />
|
<MoveUpRight size="16" class="mr-1" />
|
||||||
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
|
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
|
||||||
@@ -53,13 +56,19 @@
|
|||||||
{#if panelSize > 120 || orientation === 'horizontal'}
|
{#if panelSize > 120 || orientation === 'horizontal'}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
|
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
|
||||||
label="{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_(
|
label="{$velocityUnits === 'speed'
|
||||||
'quantities.moving'
|
? i18n._('quantities.speed')
|
||||||
)} / {$_('quantities.total')})"
|
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
|
||||||
|
'quantities.total'
|
||||||
|
)})"
|
||||||
>
|
>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
<Zap size="16" class="mr-1" />
|
<Zap size="16" class="mr-1" />
|
||||||
<WithUnits value={statistics.global.speed.moving} type="speed" showUnits={false} />
|
<WithUnits
|
||||||
|
value={statistics.global.speed.moving}
|
||||||
|
type="speed"
|
||||||
|
showUnits={false}
|
||||||
|
/>
|
||||||
<span class="mx-1">/</span>
|
<span class="mx-1">/</span>
|
||||||
<WithUnits value={statistics.global.speed.total} type="speed" />
|
<WithUnits value={statistics.global.speed.total} type="speed" />
|
||||||
</span>
|
</span>
|
||||||
@@ -68,7 +77,9 @@
|
|||||||
{#if panelSize > 160 || orientation === 'horizontal'}
|
{#if panelSize > 160 || orientation === 'horizontal'}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
|
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
|
||||||
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_('quantities.total')})"
|
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
|
||||||
|
'quantities.total'
|
||||||
|
)})"
|
||||||
>
|
>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
<Timer size="16" class="mr-1" />
|
<Timer size="16" class="mr-1" />
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleHelp } from 'lucide-svelte';
|
import { CircleQuestionMark } from '@lucide/svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {className}">
|
||||||
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
|
<CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
||||||
>
|
|
||||||
<CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
|
||||||
<div>
|
<div>
|
||||||
<slot />
|
{@render children()}
|
||||||
{#if link}
|
{#if link}
|
||||||
<a href={link} target="_blank" class="text-sm text-link hover:underline">
|
<a href={link} target="_blank" class="text-sm text-link hover:underline">
|
||||||
{$_('menu.more')}
|
{i18n._('menu.more')}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,51 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { languages } from '$lib/languages';
|
import { languages } from '$lib/languages';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { Languages } from 'lucide-svelte';
|
import { Languages } from '@lucide/svelte';
|
||||||
import { _, locale } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
let selected = {
|
let {
|
||||||
value: '',
|
class: className = '',
|
||||||
label: ''
|
}: {
|
||||||
};
|
class?: string;
|
||||||
|
} = $props();
|
||||||
$: if ($locale) {
|
|
||||||
selected = {
|
|
||||||
value: $locale,
|
|
||||||
label: languages[$locale]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Select.Root bind:selected>
|
<Select.Root type="single" value={i18n.lang}>
|
||||||
<Select.Trigger class="w-[180px] {$$props.class ?? ''}" aria-label={$_('menu.language')}>
|
<Select.Trigger class="min-w-[180px] {className}" aria-label={i18n._('menu.language')}>
|
||||||
<Languages size="16" />
|
<Languages size="16" />
|
||||||
<Select.Value class="ml-2 mr-auto" />
|
<span class="mr-auto">
|
||||||
|
{languages[i18n.lang]}
|
||||||
|
</span>
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each Object.entries(languages) as [lang, label]}
|
{#each Object.entries(languages) as [lang, label]}
|
||||||
{#if $page.url.pathname.includes('404')}
|
{#if page.url.pathname.includes('404')}
|
||||||
<a href={getURLForLanguage(lang, '/')}>
|
<a href={getURLForLanguage(lang, '/')}>
|
||||||
<Select.Item value={lang}>{label}</Select.Item>
|
<Select.Item value={lang}>{label}</Select.Item>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<a href={getURLForLanguage(lang, $page.url.pathname)}>
|
<a href={getURLForLanguage(lang, page.url.pathname)}>
|
||||||
<Select.Item value={lang}>{label}</Select.Item>
|
<Select.Item value={lang}>{label}</Select.Item>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</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,31 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { mode, systemPrefersMode } from 'mode-watcher';
|
|
||||||
|
|
||||||
export let iconOnly = false;
|
let {
|
||||||
export let company = 'gpx.studio';
|
iconOnly = false,
|
||||||
|
company = 'gpx.studio',
|
||||||
$: effectiveMode = $mode ?? $systemPrefersMode ?? 'light';
|
...others
|
||||||
|
}: {
|
||||||
|
iconOnly?: boolean;
|
||||||
|
company?: 'gpx.studio' | 'mapbox' | 'github' | 'crowdin' | 'facebook' | 'reddit';
|
||||||
|
[key: string]: any;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if company === 'gpx.studio'}
|
{#if company === 'gpx.studio'}
|
||||||
<img
|
<img
|
||||||
src="{base}/{iconOnly ? 'icon' : 'logo'}{effectiveMode === 'dark' ? '-dark' : ''}.svg"
|
src="{base}/{iconOnly ? 'icon' : 'logo'}{mode.current === 'dark' ? '-dark' : ''}.svg"
|
||||||
alt="Logo of gpx.studio."
|
alt="Logo of gpx.studio."
|
||||||
{...$$restProps}
|
{...others}
|
||||||
/>
|
/>
|
||||||
{:else if company === 'mapbox'}
|
{:else if company === 'mapbox'}
|
||||||
<img
|
<img
|
||||||
src="{base}/mapbox-logo-{effectiveMode === 'dark' ? 'white' : 'black'}.svg"
|
src="{base}/mapbox-logo-{mode.current === 'dark' ? 'white' : 'black'}.svg"
|
||||||
alt="Logo of Mapbox."
|
alt="Logo of Mapbox."
|
||||||
{...$$restProps}
|
{...others}
|
||||||
/>
|
/>
|
||||||
{:else if company === 'github'}
|
{:else if company === 'github'}
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="fill-foreground {$$restProps.class ?? ''}"
|
class="fill-foreground {others.class ?? ''}"
|
||||||
><title>GitHub</title><path
|
><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"
|
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
|
/></svg
|
||||||
@@ -35,7 +40,7 @@
|
|||||||
role="img"
|
role="img"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="fill-foreground {$$restProps.class ?? ''}"
|
class="fill-foreground {others.class ?? ''}"
|
||||||
><title>Crowdin</title><path
|
><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"
|
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
|
/></svg
|
||||||
@@ -45,27 +50,17 @@
|
|||||||
role="img"
|
role="img"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="fill-foreground {$$restProps.class ?? ''}"
|
class="fill-foreground {others.class ?? ''}"
|
||||||
><title>Facebook</title><path
|
><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"
|
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
|
/></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'}
|
{:else if company === 'reddit'}
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="fill-foreground {$$restProps.class ?? ''}"
|
class="fill-foreground {others.class ?? ''}"
|
||||||
><title>Reddit</title><path
|
><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"
|
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
|
/></svg
|
||||||
|
|||||||
@@ -1,394 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
|
|
||||||
import mapboxgl from 'mapbox-gl';
|
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
|
||||||
|
|
||||||
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
|
||||||
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
|
||||||
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
import { settings } from '$lib/db';
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
|
|
||||||
export let accessToken = PUBLIC_MAPBOX_TOKEN;
|
|
||||||
export let geolocate = true;
|
|
||||||
export let geocoder = true;
|
|
||||||
export let hash = true;
|
|
||||||
|
|
||||||
mapboxgl.accessToken = accessToken;
|
|
||||||
|
|
||||||
let webgl2Supported = true;
|
|
||||||
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
|
||||||
maxZoom: 15,
|
|
||||||
linear: true,
|
|
||||||
easing: () => 1
|
|
||||||
};
|
|
||||||
|
|
||||||
const { distanceUnits, elevationProfile, verticalFileView, bottomPanelSize, rightPanelSize } =
|
|
||||||
settings;
|
|
||||||
let scaleControl = new mapboxgl.ScaleControl({
|
|
||||||
unit: $distanceUnits
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
let gl = document.createElement('canvas').getContext('webgl2');
|
|
||||||
if (!gl) {
|
|
||||||
webgl2Supported = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let language = $page.params.language;
|
|
||||||
if (language === 'zh') {
|
|
||||||
language = 'zh-Hans';
|
|
||||||
} else if (language?.includes('-')) {
|
|
||||||
language = language.split('-')[0];
|
|
||||||
} else if (language === '' || language === undefined) {
|
|
||||||
language = 'en';
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadJson = mapboxgl.Style.prototype._load;
|
|
||||||
mapboxgl.Style.prototype._load = function (json, validate) {
|
|
||||||
if (
|
|
||||||
json['sources'] &&
|
|
||||||
json['sources']['mapbox-satellite'] &&
|
|
||||||
json['sources']['mapbox-satellite']['data'] &&
|
|
||||||
json['sources']['mapbox-satellite']['data']['data']
|
|
||||||
) {
|
|
||||||
// Temporary fix for https://github.com/gpxstudio/gpx.studio/issues/129
|
|
||||||
delete json['sources']['mapbox-satellite']['data']['data'];
|
|
||||||
}
|
|
||||||
loadJson.call(this, json, validate);
|
|
||||||
};
|
|
||||||
|
|
||||||
let newMap = new mapboxgl.Map({
|
|
||||||
container: 'map',
|
|
||||||
style: {
|
|
||||||
version: 8,
|
|
||||||
sources: {},
|
|
||||||
layers: [],
|
|
||||||
imports: [
|
|
||||||
{
|
|
||||||
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
|
|
||||||
url: '',
|
|
||||||
data: {
|
|
||||||
version: 8,
|
|
||||||
sources: {},
|
|
||||||
layers: [],
|
|
||||||
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
|
|
||||||
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'basemap',
|
|
||||||
url: ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'overlays',
|
|
||||||
url: '',
|
|
||||||
data: {
|
|
||||||
version: 8,
|
|
||||||
sources: {},
|
|
||||||
layers: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
zoom: 0,
|
|
||||||
hash: hash,
|
|
||||||
language,
|
|
||||||
attributionControl: false,
|
|
||||||
logoPosition: 'bottom-right',
|
|
||||||
boxZoom: false
|
|
||||||
});
|
|
||||||
newMap.on('load', () => {
|
|
||||||
$map = newMap; // only set the store after the map has loaded
|
|
||||||
window._map = newMap; // entry point for extensions
|
|
||||||
scaleControl.setUnit($distanceUnits);
|
|
||||||
});
|
|
||||||
|
|
||||||
newMap.addControl(
|
|
||||||
new mapboxgl.AttributionControl({
|
|
||||||
compact: true
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
newMap.addControl(
|
|
||||||
new mapboxgl.NavigationControl({
|
|
||||||
visualizePitch: true
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (geocoder) {
|
|
||||||
let geocoder = new MapboxGeocoder({
|
|
||||||
mapboxgl: mapboxgl,
|
|
||||||
enableEventLogging: false,
|
|
||||||
collapsed: true,
|
|
||||||
flyTo: fitBoundsOptions,
|
|
||||||
language,
|
|
||||||
localGeocoder: () => [],
|
|
||||||
localGeocoderOnly: true,
|
|
||||||
externalGeocoder: (query: string) =>
|
|
||||||
fetch(
|
|
||||||
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
|
|
||||||
)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
return data.map((result: any) => {
|
|
||||||
return {
|
|
||||||
type: 'Feature',
|
|
||||||
geometry: {
|
|
||||||
type: 'Point',
|
|
||||||
coordinates: [result.lon, result.lat]
|
|
||||||
},
|
|
||||||
place_name: result.display_name
|
|
||||||
};
|
|
||||||
});
|
|
||||||
})
|
|
||||||
});
|
|
||||||
let onKeyDown = geocoder._onKeyDown;
|
|
||||||
geocoder._onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
// Trigger search on Enter key only
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
|
|
||||||
} else if (geocoder._typeahead.data.length > 0) {
|
|
||||||
geocoder._typeahead.clear();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
newMap.addControl(geocoder);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (geolocate) {
|
|
||||||
newMap.addControl(
|
|
||||||
new mapboxgl.GeolocateControl({
|
|
||||||
positionOptions: {
|
|
||||||
enableHighAccuracy: true
|
|
||||||
},
|
|
||||||
fitBoundsOptions,
|
|
||||||
trackUserLocation: true,
|
|
||||||
showUserHeading: true
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
newMap.addControl(scaleControl);
|
|
||||||
|
|
||||||
newMap.on('style.load', () => {
|
|
||||||
newMap.addSource('mapbox-dem', {
|
|
||||||
type: 'raster-dem',
|
|
||||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
|
||||||
tileSize: 512,
|
|
||||||
maxzoom: 14
|
|
||||||
});
|
|
||||||
if (newMap.getPitch() > 0) {
|
|
||||||
newMap.setTerrain({
|
|
||||||
source: 'mapbox-dem',
|
|
||||||
exaggeration: 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
newMap.setFog({
|
|
||||||
color: 'rgb(186, 210, 235)',
|
|
||||||
'high-color': 'rgb(36, 92, 223)',
|
|
||||||
'horizon-blend': 0.1,
|
|
||||||
'space-color': 'rgb(156, 240, 255)'
|
|
||||||
});
|
|
||||||
newMap.on('pitch', () => {
|
|
||||||
if (newMap.getPitch() > 0) {
|
|
||||||
newMap.setTerrain({
|
|
||||||
source: 'mapbox-dem',
|
|
||||||
exaggeration: 1
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
newMap.setTerrain(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if ($map) {
|
|
||||||
$map.remove();
|
|
||||||
$map = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$: if (
|
|
||||||
$map &&
|
|
||||||
(!$verticalFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)
|
|
||||||
) {
|
|
||||||
$map.resize();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div {...$$restProps}>
|
|
||||||
<div id="map" class="h-full {webgl2Supported ? '' : 'hidden'}"></div>
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported ? 'hidden' : ''}"
|
|
||||||
>
|
|
||||||
<p>{$_('webgl2_required')}</p>
|
|
||||||
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
|
||||||
{$_('enable_webgl2')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="postcss">
|
|
||||||
div :global(.mapboxgl-map) {
|
|
||||||
@apply font-sans;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
|
|
||||||
@apply shadow-md;
|
|
||||||
@apply bg-background;
|
|
||||||
@apply text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-icon) {
|
|
||||||
@apply dark:brightness-[4.7];
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder) {
|
|
||||||
@apply flex;
|
|
||||||
@apply flex-row;
|
|
||||||
@apply w-fit;
|
|
||||||
@apply min-w-fit;
|
|
||||||
@apply items-center;
|
|
||||||
@apply shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.suggestions) {
|
|
||||||
@apply shadow-md;
|
|
||||||
@apply bg-background;
|
|
||||||
@apply text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
|
|
||||||
@apply text-foreground;
|
|
||||||
@apply hover:text-accent-foreground;
|
|
||||||
@apply hover:bg-accent;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
|
|
||||||
@apply bg-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--button) {
|
|
||||||
@apply bg-transparent;
|
|
||||||
@apply hover:bg-transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--icon) {
|
|
||||||
@apply fill-foreground;
|
|
||||||
@apply hover:fill-accent-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
|
|
||||||
@apply relative;
|
|
||||||
@apply top-0;
|
|
||||||
@apply left-0;
|
|
||||||
@apply my-2;
|
|
||||||
@apply w-[29px];
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--input) {
|
|
||||||
@apply relative;
|
|
||||||
@apply w-64;
|
|
||||||
@apply py-0;
|
|
||||||
@apply pl-2;
|
|
||||||
@apply focus:outline-none;
|
|
||||||
@apply transition-[width];
|
|
||||||
@apply duration-200;
|
|
||||||
@apply text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
|
|
||||||
@apply w-0;
|
|
||||||
@apply p-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-top-right) {
|
|
||||||
@apply z-40;
|
|
||||||
@apply flex;
|
|
||||||
@apply flex-col;
|
|
||||||
@apply items-end;
|
|
||||||
@apply h-full;
|
|
||||||
@apply overflow-hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
|
|
||||||
@apply bottom-[42px];
|
|
||||||
}
|
|
||||||
|
|
||||||
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
|
||||||
@apply bottom-[42px];
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-attrib) {
|
|
||||||
@apply dark:bg-transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
|
||||||
@apply dark:bg-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-attrib-button) {
|
|
||||||
@apply dark:bg-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
|
|
||||||
@apply dark:bg-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-attrib a) {
|
|
||||||
@apply text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup) {
|
|
||||||
@apply w-fit;
|
|
||||||
@apply z-50;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-content) {
|
|
||||||
@apply p-0;
|
|
||||||
@apply bg-transparent;
|
|
||||||
@apply shadow-none;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
|
|
||||||
@apply border-b-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
|
|
||||||
@apply border-b-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
|
|
||||||
@apply border-b-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
|
|
||||||
@apply border-t-background;
|
|
||||||
@apply drop-shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
|
|
||||||
@apply border-t-background;
|
|
||||||
@apply drop-shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
|
|
||||||
@apply border-t-background;
|
|
||||||
@apply drop-shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
|
|
||||||
@apply border-r-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
|
||||||
@apply border-l-background;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<svelte:options accessors />
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { TrackPoint, Waypoint } from 'gpx';
|
|
||||||
import type { Writable } from 'svelte/store';
|
|
||||||
import WaypointPopup from '$lib/components/gpx-layer/WaypointPopup.svelte';
|
|
||||||
import TrackpointPopup from '$lib/components/gpx-layer/TrackpointPopup.svelte';
|
|
||||||
import OverpassPopup from '$lib/components/layer-control/OverpassPopup.svelte';
|
|
||||||
import type { PopupItem } from './MapPopup';
|
|
||||||
|
|
||||||
export let item: Writable<PopupItem | null>;
|
|
||||||
export let container: HTMLDivElement | null = null;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div bind:this={container}>
|
|
||||||
{#if $item}
|
|
||||||
{#if $item.item instanceof Waypoint}
|
|
||||||
<WaypointPopup waypoint={$item} />
|
|
||||||
{:else if $item.item instanceof TrackPoint}
|
|
||||||
<TrackpointPopup trackpoint={$item} />
|
|
||||||
{:else}
|
|
||||||
<OverpassPopup poi={$item} />
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
Layers,
|
Layers,
|
||||||
GalleryVertical,
|
ListTree,
|
||||||
Languages,
|
Languages,
|
||||||
Settings,
|
Settings,
|
||||||
Info,
|
Info,
|
||||||
@@ -42,48 +42,43 @@
|
|||||||
FileX,
|
FileX,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
ChartArea,
|
ChartArea,
|
||||||
Maximize
|
Maximize,
|
||||||
} from 'lucide-svelte';
|
} from '@lucide/svelte';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
import {
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
map,
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||||
triggerFileInput,
|
import { exportState, ExportState } from '$lib/components/export/utils.svelte';
|
||||||
createFile,
|
import { anySelectedLayer } from '$lib/components/map/layer-control/utils';
|
||||||
loadFiles,
|
|
||||||
updateSelectionFromKey,
|
|
||||||
allHidden,
|
|
||||||
editMetadata,
|
|
||||||
editStyle,
|
|
||||||
exportState,
|
|
||||||
ExportState,
|
|
||||||
centerMapOnSelection
|
|
||||||
} from '$lib/stores';
|
|
||||||
import {
|
|
||||||
copied,
|
|
||||||
copySelection,
|
|
||||||
cutSelection,
|
|
||||||
pasteSelection,
|
|
||||||
selectAll,
|
|
||||||
selection
|
|
||||||
} from '$lib/components/file-list/Selection';
|
|
||||||
import { derived } from 'svelte/store';
|
|
||||||
import { canUndo, canRedo, dbUtils, fileObservers, settings } from '$lib/db';
|
|
||||||
import { anySelectedLayer } from '$lib/components/layer-control/utils';
|
|
||||||
import { defaultOverlays } from '$lib/assets/layers';
|
import { defaultOverlays } from '$lib/assets/layers';
|
||||||
import LayerControlSettings from '$lib/components/layer-control/LayerControlSettings.svelte';
|
import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte';
|
||||||
import { allowedPastes, ListFileItem, ListTrackItem } from '$lib/components/file-list/FileList';
|
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/file-list';
|
||||||
import Export from '$lib/components/Export.svelte';
|
import Export from '$lib/components/export/Export.svelte';
|
||||||
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import { _, locale } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { languages } from '$lib/languages';
|
import { languages } from '$lib/languages';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import {
|
||||||
|
createFile,
|
||||||
|
fileActions,
|
||||||
|
loadFiles,
|
||||||
|
pasteSelection,
|
||||||
|
triggerFileInput,
|
||||||
|
} from '$lib/logic/file-actions';
|
||||||
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import { fileActionManager } from '$lib/logic/file-action-manager';
|
||||||
|
import { copied, selection } from '$lib/logic/selection';
|
||||||
|
import { allHidden } from '$lib/logic/hidden';
|
||||||
|
import { boundsManager } from '$lib/logic/bounds';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
distanceUnits,
|
distanceUnits,
|
||||||
velocityUnits,
|
velocityUnits,
|
||||||
temperatureUnits,
|
temperatureUnits,
|
||||||
elevationProfile,
|
elevationProfile,
|
||||||
verticalFileView,
|
treeFileView,
|
||||||
currentBasemap,
|
currentBasemap,
|
||||||
previousBasemap,
|
previousBasemap,
|
||||||
currentOverlays,
|
currentOverlays,
|
||||||
@@ -91,115 +86,112 @@
|
|||||||
distanceMarkers,
|
distanceMarkers,
|
||||||
directionMarkers,
|
directionMarkers,
|
||||||
streetViewSource,
|
streetViewSource,
|
||||||
routing
|
routing,
|
||||||
} = settings;
|
} = settings;
|
||||||
|
|
||||||
let undoDisabled = derived(canUndo, ($canUndo) => !$canUndo);
|
const canUndo = fileActionManager.canUndo;
|
||||||
let redoDisabled = derived(canRedo, ($canRedo) => !$canRedo);
|
const canRedo = fileActionManager.canRedo;
|
||||||
|
|
||||||
function switchBasemaps() {
|
function switchBasemaps() {
|
||||||
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
|
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOverlays() {
|
function toggleOverlays() {
|
||||||
if (anySelectedLayer($currentOverlays)) {
|
if ($currentOverlays && anySelectedLayer($currentOverlays)) {
|
||||||
[$currentOverlays, $previousOverlays] = [defaultOverlays, $currentOverlays];
|
[$currentOverlays, $previousOverlays] = [defaultOverlays, $currentOverlays];
|
||||||
} else {
|
} else {
|
||||||
[$currentOverlays, $previousOverlays] = [$previousOverlays, defaultOverlays];
|
[$currentOverlays, $previousOverlays] = [$previousOverlays, defaultOverlays];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle3D() {
|
let layerSettingsOpen = $state(false);
|
||||||
if ($map) {
|
|
||||||
if ($map.getPitch() === 0) {
|
|
||||||
$map.easeTo({ pitch: 70 });
|
|
||||||
} else {
|
|
||||||
$map.easeTo({ pitch: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let layerSettingsOpen = false;
|
|
||||||
|
|
||||||
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute md:top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none">
|
<div class="absolute md:top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none">
|
||||||
<div
|
<div
|
||||||
class="w-fit flex flex-row items-center justify-center p-1 bg-background rounded-b-md md:rounded-md pointer-events-auto shadow-md"
|
class="w-fit flex flex-row items-center justify-center p-1 bg-background rounded-b-md md:rounded-md pointer-events-auto shadow-md"
|
||||||
>
|
>
|
||||||
<a href="./" target="_blank" class="shrink-0">
|
<a href={getURLForLanguage(i18n.lang, '/')} target="_blank" class="shrink-0">
|
||||||
<Logo class="h-5 mt-0.5 mx-2 md:hidden" iconOnly={true} width="16" />
|
<Logo class="h-5 mt-0.5 mx-2 md:hidden" iconOnly={true} width="16" />
|
||||||
<Logo class="h-5 mt-0.5 mx-2 hidden md:block" width="96" />
|
<Logo class="h-5 mt-0.5 mx-2 hidden md:block" width="96" />
|
||||||
</a>
|
</a>
|
||||||
<Menubar.Root class="border-none h-fit p-0">
|
<Menubar.Root class="border-none shadow-none h-fit p-0">
|
||||||
<Menubar.Menu>
|
<Menubar.Menu>
|
||||||
<Menubar.Trigger aria-label={$_('gpx.file')}>
|
<Menubar.Trigger aria-label={i18n._('gpx.file')}>
|
||||||
<File size="18" class="md:hidden" />
|
<File size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">{$_('gpx.file')}</span>
|
<span class="hidden md:block">{i18n._('gpx.file')}</span>
|
||||||
</Menubar.Trigger>
|
</Menubar.Trigger>
|
||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.Item on:click={createFile}>
|
<Menubar.Item onclick={createFile}>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" />
|
||||||
{$_('menu.new')}
|
{i18n._('menu.new')}
|
||||||
<Shortcut key="+" ctrl={true} />
|
<Shortcut key="+" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item on:click={triggerFileInput}>
|
<Menubar.Item onclick={triggerFileInput}>
|
||||||
<FolderOpen size="16" class="mr-1" />
|
<FolderOpen size="16" />
|
||||||
{$_('menu.open')}
|
{i18n._('menu.open')}
|
||||||
<Shortcut key="O" ctrl={true} />
|
<Shortcut key="O" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item on:click={dbUtils.duplicateSelection} disabled={$selection.size == 0}>
|
<Menubar.Item
|
||||||
<Copy size="16" class="mr-1" />
|
onclick={fileActions.duplicateSelection}
|
||||||
{$_('menu.duplicate')}
|
disabled={$selection.size == 0}
|
||||||
|
>
|
||||||
|
<Copy size="16" />
|
||||||
|
{i18n._('menu.duplicate')}
|
||||||
<Shortcut key="D" ctrl={true} />
|
<Shortcut key="D" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item on:click={dbUtils.deleteSelectedFiles} disabled={$selection.size == 0}>
|
<Menubar.Item
|
||||||
<FileX size="16" class="mr-1" />
|
onclick={() => tick().then(fileActions.deleteSelectedFiles)}
|
||||||
{$_('menu.close')}
|
disabled={$selection.size == 0}
|
||||||
|
>
|
||||||
|
<FileX size="16" />
|
||||||
|
{i18n._('menu.delete')}
|
||||||
<Shortcut key="⌫" ctrl={true} />
|
<Shortcut key="⌫" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item on:click={dbUtils.deleteAllFiles} disabled={$fileObservers.size == 0}>
|
<Menubar.Item
|
||||||
<FileX size="16" class="mr-1" />
|
onclick={fileActions.deleteAllFiles}
|
||||||
{$_('menu.close_all')}
|
disabled={fileStateCollection.size == 0}
|
||||||
|
>
|
||||||
|
<FileX size="16" />
|
||||||
|
{i18n._('menu.delete_all')}
|
||||||
<Shortcut key="⌫" ctrl={true} shift={true} />
|
<Shortcut key="⌫" ctrl={true} shift={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => ($exportState = ExportState.SELECTION)}
|
onclick={() => (exportState.current = ExportState.SELECTION)}
|
||||||
disabled={$selection.size == 0}
|
disabled={$selection.size == 0}
|
||||||
>
|
>
|
||||||
<Download size="16" class="mr-1" />
|
<Download size="16" />
|
||||||
{$_('menu.export')}
|
{i18n._('menu.export')}
|
||||||
<Shortcut key="S" ctrl={true} />
|
<Shortcut key="S" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => ($exportState = ExportState.ALL)}
|
onclick={() => (exportState.current = ExportState.ALL)}
|
||||||
disabled={$fileObservers.size == 0}
|
disabled={fileStateCollection.size == 0}
|
||||||
>
|
>
|
||||||
<Download size="16" class="mr-1" />
|
<Download size="16" />
|
||||||
{$_('menu.export_all')}
|
{i18n._('menu.export_all')}
|
||||||
<Shortcut key="S" ctrl={true} shift={true} />
|
<Shortcut key="S" ctrl={true} shift={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
</Menubar.Content>
|
</Menubar.Content>
|
||||||
</Menubar.Menu>
|
</Menubar.Menu>
|
||||||
<Menubar.Menu>
|
<Menubar.Menu>
|
||||||
<Menubar.Trigger aria-label={$_('menu.edit')}>
|
<Menubar.Trigger aria-label={i18n._('menu.edit')}>
|
||||||
<FilePen size="18" class="md:hidden" />
|
<FilePen size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">{$_('menu.edit')}</span>
|
<span class="hidden md:block">{i18n._('menu.edit')}</span>
|
||||||
</Menubar.Trigger>
|
</Menubar.Trigger>
|
||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.Item on:click={dbUtils.undo} disabled={$undoDisabled}>
|
<Menubar.Item onclick={() => fileActionManager.undo()} disabled={!$canUndo}>
|
||||||
<Undo2 size="16" class="mr-1" />
|
<Undo2 size="16" />
|
||||||
{$_('menu.undo')}
|
{i18n._('menu.undo')}
|
||||||
<Shortcut key="Z" ctrl={true} />
|
<Shortcut key="Z" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item on:click={dbUtils.redo} disabled={$redoDisabled}>
|
<Menubar.Item onclick={() => fileActionManager.redo()} disabled={!$canRedo}>
|
||||||
<Redo2 size="16" class="mr-1" />
|
<Redo2 size="16" />
|
||||||
{$_('menu.redo')}
|
{i18n._('menu.redo')}
|
||||||
<Shortcut key="Z" ctrl={true} shift={true} />
|
<Shortcut key="Z" ctrl={true} shift={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
@@ -207,203 +199,250 @@
|
|||||||
disabled={$selection.size !== 1 ||
|
disabled={$selection.size !== 1 ||
|
||||||
!$selection
|
!$selection
|
||||||
.getSelected()
|
.getSelected()
|
||||||
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)}
|
.every(
|
||||||
on:click={() => ($editMetadata = true)}
|
(item) =>
|
||||||
|
item instanceof ListFileItem ||
|
||||||
|
item instanceof ListTrackItem
|
||||||
|
)}
|
||||||
|
onclick={() => (editMetadata.current = true)}
|
||||||
>
|
>
|
||||||
<Info size="16" class="mr-1" />
|
<Info size="16" />
|
||||||
{$_('menu.metadata.button')}
|
{i18n._('menu.metadata.button')}
|
||||||
<Shortcut key="I" ctrl={true} />
|
<Shortcut key="I" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
disabled={$selection.size === 0 ||
|
disabled={$selection.size === 0 ||
|
||||||
!$selection
|
!$selection
|
||||||
.getSelected()
|
.getSelected()
|
||||||
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)}
|
.every(
|
||||||
on:click={() => ($editStyle = true)}
|
(item) =>
|
||||||
|
item instanceof ListFileItem ||
|
||||||
|
item instanceof ListTrackItem
|
||||||
|
)}
|
||||||
|
onclick={() => (editStyle.current = true)}
|
||||||
>
|
>
|
||||||
<PaintBucket size="16" class="mr-1" />
|
<PaintBucket size="16" />
|
||||||
{$_('menu.style.button')}
|
{i18n._('menu.style.button')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
if ($allHidden) {
|
if ($allHidden) {
|
||||||
dbUtils.setHiddenToSelection(false);
|
fileActions.setHiddenToSelection(false);
|
||||||
} else {
|
} else {
|
||||||
dbUtils.setHiddenToSelection(true);
|
fileActions.setHiddenToSelection(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={$selection.size == 0}
|
disabled={$selection.size == 0}
|
||||||
>
|
>
|
||||||
{#if $allHidden}
|
{#if $allHidden}
|
||||||
<Eye size="16" class="mr-1" />
|
<Eye size="16" />
|
||||||
{$_('menu.unhide')}
|
{i18n._('menu.unhide')}
|
||||||
{:else}
|
{:else}
|
||||||
<EyeOff size="16" class="mr-1" />
|
<EyeOff size="16" />
|
||||||
{$_('menu.hide')}
|
{i18n._('menu.hide')}
|
||||||
{/if}
|
{/if}
|
||||||
<Shortcut key="H" ctrl={true} />
|
<Shortcut key="H" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{#if $verticalFileView}
|
{#if $treeFileView}
|
||||||
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
|
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => dbUtils.addNewTrack($selection.getSelected()[0].getFileId())}
|
onclick={() =>
|
||||||
|
fileActions.addNewTrack(
|
||||||
|
$selection.getSelected()[0].getFileId()
|
||||||
|
)}
|
||||||
disabled={$selection.size !== 1}
|
disabled={$selection.size !== 1}
|
||||||
>
|
>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" />
|
||||||
{$_('menu.new_track')}
|
{i18n._('menu.new_track')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{:else if $selection.getSelected().some((item) => item instanceof ListTrackItem)}
|
{:else if $selection
|
||||||
|
.getSelected()
|
||||||
|
.some((item) => item instanceof ListTrackItem)}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
let item = $selection.getSelected()[0];
|
let item = $selection.getSelected()[0];
|
||||||
dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex());
|
fileActions.addNewSegment(
|
||||||
|
item.getFileId(),
|
||||||
|
item.getTrackIndex()
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
disabled={$selection.size !== 1}
|
disabled={$selection.size !== 1}
|
||||||
>
|
>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" />
|
||||||
{$_('menu.new_segment')}
|
{i18n._('menu.new_segment')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item on:click={selectAll} disabled={$fileObservers.size == 0}>
|
<Menubar.Item
|
||||||
<FileStack size="16" class="mr-1" />
|
onclick={() => selection.selectAll()}
|
||||||
{$_('menu.select_all')}
|
disabled={fileStateCollection.size == 0}
|
||||||
|
>
|
||||||
|
<FileStack size="16" />
|
||||||
|
{i18n._('menu.select_all')}
|
||||||
<Shortcut key="A" ctrl={true} />
|
<Shortcut key="A" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
if ($selection.size > 0) {
|
if ($selection.size > 0) {
|
||||||
centerMapOnSelection();
|
boundsManager.centerMapOnSelection();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
disabled={$selection.size == 0}
|
||||||
>
|
>
|
||||||
<Maximize size="16" class="mr-1" />
|
<Maximize size="16" />
|
||||||
{$_('menu.center')}
|
{i18n._('menu.center')}
|
||||||
<Shortcut key="⏎" ctrl={true} />
|
<Shortcut key="⏎" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{#if $verticalFileView}
|
{#if $treeFileView}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item on:click={copySelection} disabled={$selection.size === 0}>
|
<Menubar.Item
|
||||||
<ClipboardCopy size="16" class="mr-1" />
|
onclick={() => selection.copySelection()}
|
||||||
{$_('menu.copy')}
|
disabled={$selection.size === 0}
|
||||||
|
>
|
||||||
|
<ClipboardCopy size="16" />
|
||||||
|
{i18n._('menu.copy')}
|
||||||
<Shortcut key="C" ctrl={true} />
|
<Shortcut key="C" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item on:click={cutSelection} disabled={$selection.size === 0}>
|
<Menubar.Item
|
||||||
<Scissors size="16" class="mr-1" />
|
onclick={() => selection.cutSelection()}
|
||||||
{$_('menu.cut')}
|
disabled={$selection.size === 0}
|
||||||
|
>
|
||||||
|
<Scissors size="16" />
|
||||||
|
{i18n._('menu.cut')}
|
||||||
<Shortcut key="X" ctrl={true} />
|
<Shortcut key="X" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
disabled={$copied === undefined ||
|
disabled={$copied === undefined ||
|
||||||
$copied.length === 0 ||
|
$copied.length === 0 ||
|
||||||
($selection.size > 0 &&
|
($selection.size > 0 &&
|
||||||
!allowedPastes[$copied[0].level].includes($selection.getSelected().pop()?.level))}
|
!allowedPastes[$copied[0].level].includes(
|
||||||
on:click={pasteSelection}
|
$selection.getSelected().pop()!.level
|
||||||
|
))}
|
||||||
|
onclick={pasteSelection}
|
||||||
>
|
>
|
||||||
<ClipboardPaste size="16" class="mr-1" />
|
<ClipboardPaste size="16" />
|
||||||
{$_('menu.paste')}
|
{i18n._('menu.paste')}
|
||||||
<Shortcut key="V" ctrl={true} />
|
<Shortcut key="V" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{/if}
|
{/if}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item on:click={dbUtils.deleteSelection} disabled={$selection.size == 0}>
|
<Menubar.Item
|
||||||
<Trash2 size="16" class="mr-1" />
|
onclick={() => tick().then(fileActions.deleteSelection)}
|
||||||
{$_('menu.delete')}
|
disabled={$selection.size == 0}
|
||||||
|
>
|
||||||
|
<Trash2 size="16" />
|
||||||
|
{i18n._('menu.delete')}
|
||||||
<Shortcut key="⌫" ctrl={true} />
|
<Shortcut key="⌫" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
</Menubar.Content>
|
</Menubar.Content>
|
||||||
</Menubar.Menu>
|
</Menubar.Menu>
|
||||||
<Menubar.Menu>
|
<Menubar.Menu>
|
||||||
<Menubar.Trigger aria-label={$_('menu.view')}>
|
<Menubar.Trigger aria-label={i18n._('menu.view')}>
|
||||||
<View size="18" class="md:hidden" />
|
<View size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">{$_('menu.view')}</span>
|
<span class="hidden md:block">{i18n._('menu.view')}</span>
|
||||||
</Menubar.Trigger>
|
</Menubar.Trigger>
|
||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.CheckboxItem bind:checked={$elevationProfile}>
|
<Menubar.CheckboxItem bind:checked={$elevationProfile}>
|
||||||
<ChartArea size="16" class="mr-1" />
|
<ChartArea size="16" />
|
||||||
{$_('menu.elevation_profile')}
|
{i18n._('menu.elevation_profile')}
|
||||||
<Shortcut key="P" ctrl={true} />
|
<Shortcut key="P" ctrl={true} />
|
||||||
</Menubar.CheckboxItem>
|
</Menubar.CheckboxItem>
|
||||||
<Menubar.CheckboxItem bind:checked={$verticalFileView}>
|
<Menubar.CheckboxItem bind:checked={$treeFileView}>
|
||||||
<GalleryVertical size="16" class="mr-1" />
|
<ListTree size="16" />
|
||||||
{$_('menu.vertical_file_view')}
|
{i18n._('menu.tree_file_view')}
|
||||||
<Shortcut key="L" ctrl={true} />
|
<Shortcut key="L" ctrl={true} />
|
||||||
</Menubar.CheckboxItem>
|
</Menubar.CheckboxItem>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item inset on:click={switchBasemaps}>
|
<Menubar.Item inset onclick={switchBasemaps}>
|
||||||
<Map size="16" class="mr-1" />{$_('menu.switch_basemap')}<Shortcut key="F1" />
|
<Map size="16" />{i18n._('menu.switch_basemap')}<Shortcut key="F1" />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item inset on:click={toggleOverlays}>
|
<Menubar.Item inset onclick={toggleOverlays}>
|
||||||
<Layers2 size="16" class="mr-1" />{$_('menu.toggle_overlays')}<Shortcut key="F2" />
|
<Layers2 size="16" />{i18n._('menu.toggle_overlays')}<Shortcut key="F2" />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.CheckboxItem bind:checked={$distanceMarkers}>
|
<Menubar.CheckboxItem bind:checked={$distanceMarkers}>
|
||||||
<Coins size="16" class="mr-1" />{$_('menu.distance_markers')}<Shortcut key="F3" />
|
<Coins size="16" />{i18n._('menu.distance_markers')}<Shortcut key="F3" />
|
||||||
</Menubar.CheckboxItem>
|
</Menubar.CheckboxItem>
|
||||||
<Menubar.CheckboxItem bind:checked={$directionMarkers}>
|
<Menubar.CheckboxItem bind:checked={$directionMarkers}>
|
||||||
<Milestone size="16" class="mr-1" />{$_('menu.direction_markers')}<Shortcut key="F4" />
|
<Milestone size="16" />{i18n._('menu.direction_markers')}<Shortcut
|
||||||
|
key="F4"
|
||||||
|
/>
|
||||||
</Menubar.CheckboxItem>
|
</Menubar.CheckboxItem>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item inset on:click={toggle3D}>
|
<Menubar.Item inset onclick={() => map.toggle3D()}>
|
||||||
<Box size="16" class="mr-1" />
|
<Box size="16" />
|
||||||
{$_('menu.toggle_3d')}
|
{i18n._('menu.toggle_3d')}
|
||||||
<Shortcut key="{$_('menu.ctrl')}+{$_('menu.drag')}" />
|
<Shortcut key="{i18n._('menu.ctrl')} {i18n._('menu.drag')}" />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
</Menubar.Content>
|
</Menubar.Content>
|
||||||
</Menubar.Menu>
|
</Menubar.Menu>
|
||||||
<Menubar.Menu>
|
<Menubar.Menu>
|
||||||
<Menubar.Trigger aria-label={$_('menu.settings')}>
|
<Menubar.Trigger aria-label={i18n._('menu.settings')}>
|
||||||
<Settings size="18" class="md:hidden" />
|
<Settings size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">
|
<span class="hidden md:block">
|
||||||
{$_('menu.settings')}
|
{i18n._('menu.settings')}
|
||||||
</span>
|
</span>
|
||||||
</Menubar.Trigger>
|
</Menubar.Trigger>
|
||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Ruler size="16" class="mr-1" />{$_('menu.distance_units')}
|
<Ruler size="16" class="mr-2" />{i18n._('menu.distance_units')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$distanceUnits}>
|
<Menubar.RadioGroup bind:value={$distanceUnits}>
|
||||||
<Menubar.RadioItem value="metric">{$_('menu.metric')}</Menubar.RadioItem>
|
<Menubar.RadioItem value="metric"
|
||||||
<Menubar.RadioItem value="imperial">{$_('menu.imperial')}</Menubar.RadioItem>
|
>{i18n._('menu.metric')}</Menubar.RadioItem
|
||||||
<Menubar.RadioItem value="nautical">{$_('menu.nautical')}</Menubar.RadioItem>
|
>
|
||||||
|
<Menubar.RadioItem value="imperial"
|
||||||
|
>{i18n._('menu.imperial')}</Menubar.RadioItem
|
||||||
|
>
|
||||||
|
<Menubar.RadioItem value="nautical"
|
||||||
|
>{i18n._('menu.nautical')}</Menubar.RadioItem
|
||||||
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Zap size="16" class="mr-1" />{$_('menu.velocity_units')}
|
<Zap size="16" class="mr-2" />{i18n._('menu.velocity_units')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$velocityUnits}>
|
<Menubar.RadioGroup bind:value={$velocityUnits}>
|
||||||
<Menubar.RadioItem value="speed">{$_('quantities.speed')}</Menubar.RadioItem>
|
<Menubar.RadioItem value="speed"
|
||||||
<Menubar.RadioItem value="pace">{$_('quantities.pace')}</Menubar.RadioItem>
|
>{i18n._('quantities.speed')}</Menubar.RadioItem
|
||||||
|
>
|
||||||
|
<Menubar.RadioItem value="pace"
|
||||||
|
>{i18n._('quantities.pace')}</Menubar.RadioItem
|
||||||
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Thermometer size="16" class="mr-1" />{$_('menu.temperature_units')}
|
<Thermometer size="16" class="mr-2" />{i18n._('menu.temperature_units')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$temperatureUnits}>
|
<Menubar.RadioGroup bind:value={$temperatureUnits}>
|
||||||
<Menubar.RadioItem value="celsius">{$_('menu.celsius')}</Menubar.RadioItem>
|
<Menubar.RadioItem value="celsius"
|
||||||
<Menubar.RadioItem value="fahrenheit">{$_('menu.fahrenheit')}</Menubar.RadioItem>
|
>{i18n._('menu.celsius')}</Menubar.RadioItem
|
||||||
|
>
|
||||||
|
<Menubar.RadioItem value="fahrenheit"
|
||||||
|
>{i18n._('menu.fahrenheit')}</Menubar.RadioItem
|
||||||
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Languages size="16" class="mr-1" />
|
<Languages size="16" class="mr-2" />
|
||||||
{$_('menu.language')}
|
{i18n._('menu.language')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$locale}>
|
<Menubar.RadioGroup value={i18n.lang}>
|
||||||
{#each Object.entries(languages) as [lang, label]}
|
{#each Object.entries(languages) as [lang, label]}
|
||||||
<a href={getURLForLanguage(lang, '/app')}>
|
<a href={getURLForLanguage(lang, '/app')}>
|
||||||
<Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem>
|
<Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem>
|
||||||
@@ -414,41 +453,49 @@
|
|||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
{#if selectedMode === 'light'}
|
{#if mode.current === 'light' || !mode.current}
|
||||||
<Sun size="16" class="mr-1" />
|
<Sun size="16" class="mr-2" />
|
||||||
{:else}
|
{:else}
|
||||||
<Moon size="16" class="mr-1" />
|
<Moon size="16" class="mr-2" />
|
||||||
{/if}
|
{/if}
|
||||||
{$_('menu.mode')}
|
{i18n._('menu.mode')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup
|
<Menubar.RadioGroup
|
||||||
bind:value={selectedMode}
|
value={mode.current ?? 'light'}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setMode(value);
|
setMode(value as 'light' | 'dark');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="light">{$_('menu.light')}</Menubar.RadioItem>
|
<Menubar.RadioItem value="light"
|
||||||
<Menubar.RadioItem value="dark">{$_('menu.dark')}</Menubar.RadioItem>
|
>{i18n._('menu.light')}</Menubar.RadioItem
|
||||||
|
>
|
||||||
|
<Menubar.RadioItem value="dark"
|
||||||
|
>{i18n._('menu.dark')}</Menubar.RadioItem
|
||||||
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<PersonStanding size="16" class="mr-1" />
|
<PersonStanding size="16" class="mr-2" />
|
||||||
{$_('menu.street_view_source')}
|
{i18n._('menu.street_view_source')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$streetViewSource}>
|
<Menubar.RadioGroup bind:value={$streetViewSource}>
|
||||||
<Menubar.RadioItem value="mapillary">{$_('menu.mapillary')}</Menubar.RadioItem>
|
<Menubar.RadioItem value="mapillary"
|
||||||
<Menubar.RadioItem value="google">{$_('menu.google')}</Menubar.RadioItem>
|
>{i18n._('menu.mapillary')}</Menubar.RadioItem
|
||||||
|
>
|
||||||
|
<Menubar.RadioItem value="google"
|
||||||
|
>{i18n._('menu.google')}</Menubar.RadioItem
|
||||||
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Item on:click={() => (layerSettingsOpen = true)}>
|
<Menubar.Item onclick={() => (layerSettingsOpen = true)}>
|
||||||
<Layers size="16" class="mr-1" />
|
<Layers size="16" />
|
||||||
{$_('menu.layers')}
|
{i18n._('menu.layers')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
</Menubar.Content>
|
</Menubar.Content>
|
||||||
</Menubar.Menu>
|
</Menubar.Menu>
|
||||||
@@ -459,11 +506,11 @@
|
|||||||
href="./help"
|
href="./help"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="cursor-default h-fit rounded-sm px-3 py-0.5"
|
class="cursor-default h-fit rounded-sm px-3 py-0.5"
|
||||||
aria-label={$_('menu.help')}
|
aria-label={i18n._('menu.help')}
|
||||||
>
|
>
|
||||||
<BookOpenText size="18" class="md:hidden" />
|
<BookOpenText size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">
|
<span class="hidden md:block">
|
||||||
{$_('menu.help')}
|
{i18n._('menu.help')}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -471,12 +518,12 @@
|
|||||||
href="https://ko-fi.com/gpxstudio"
|
href="https://ko-fi.com/gpxstudio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5"
|
class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5"
|
||||||
aria-label={$_('menu.donate')}
|
aria-label={i18n._('menu.donate')}
|
||||||
>
|
>
|
||||||
<HeartHandshake size="18" class="md:hidden" />
|
<HeartHandshake size="18" class="md:hidden" />
|
||||||
<span class="hidden md:flex flex-row items-center">
|
<span class="hidden md:flex flex-row items-center">
|
||||||
{$_('menu.donate')}
|
{i18n._('menu.donate')}
|
||||||
<Heart size="16" class="ml-1" fill="rgb(var(--support))" />
|
<Heart size="16" class="ml-1" fill="var(--support)" />
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -489,7 +536,9 @@
|
|||||||
<svelte:window
|
<svelte:window
|
||||||
on:keydown={(e) => {
|
on:keydown={(e) => {
|
||||||
let targetInput =
|
let targetInput =
|
||||||
e.target.tagName === 'INPUT' ||
|
e &&
|
||||||
|
e.target &&
|
||||||
|
(e.target.tagName === 'INPUT' ||
|
||||||
e.target.tagName === 'TEXTAREA' ||
|
e.target.tagName === 'TEXTAREA' ||
|
||||||
e.target.tagName === 'SELECT' ||
|
e.target.tagName === 'SELECT' ||
|
||||||
e.target.role === 'combobox' ||
|
e.target.role === 'combobox' ||
|
||||||
@@ -497,7 +546,7 @@
|
|||||||
e.target.role === 'menu' ||
|
e.target.role === 'menu' ||
|
||||||
e.target.role === 'menuitem' ||
|
e.target.role === 'menuitem' ||
|
||||||
e.target.role === 'menuitemradio' ||
|
e.target.role === 'menuitemradio' ||
|
||||||
e.target.role === 'menuitemcheckbox';
|
e.target.role === 'menuitemcheckbox');
|
||||||
|
|
||||||
if (e.key === '+' && (e.metaKey || e.ctrlKey)) {
|
if (e.key === '+' && (e.metaKey || e.ctrlKey)) {
|
||||||
createFile();
|
createFile();
|
||||||
@@ -506,16 +555,16 @@
|
|||||||
triggerFileInput();
|
triggerFileInput();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
|
||||||
dbUtils.duplicateSelection();
|
fileActions.duplicateSelection();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
|
||||||
if (!targetInput) {
|
if (!targetInput) {
|
||||||
copySelection();
|
selection.copySelection();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) {
|
||||||
if (!targetInput) {
|
if (!targetInput) {
|
||||||
cutSelection();
|
selection.cutSelection();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
|
||||||
@@ -525,32 +574,32 @@
|
|||||||
}
|
}
|
||||||
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
|
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
if ($fileObservers.size > 0) {
|
if (fileStateCollection.size > 0) {
|
||||||
$exportState = ExportState.ALL;
|
exportState.current = ExportState.ALL;
|
||||||
}
|
}
|
||||||
} else if ($selection.size > 0) {
|
} else if ($selection.size > 0) {
|
||||||
$exportState = ExportState.SELECTION;
|
exportState.current = ExportState.SELECTION;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) {
|
} else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
dbUtils.redo();
|
fileActionManager.redo();
|
||||||
} else {
|
} else {
|
||||||
dbUtils.undo();
|
fileActionManager.undo();
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
|
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
|
||||||
if (!targetInput) {
|
if (!targetInput) {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
dbUtils.deleteAllFiles();
|
fileActions.deleteAllFiles();
|
||||||
} else {
|
} else {
|
||||||
dbUtils.deleteSelection();
|
fileActions.deleteSelection();
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
|
||||||
if (!targetInput) {
|
if (!targetInput) {
|
||||||
selectAll();
|
selection.selectAll();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'i' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'i' && (e.metaKey || e.ctrlKey)) {
|
||||||
@@ -560,25 +609,25 @@
|
|||||||
.getSelected()
|
.getSelected()
|
||||||
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
|
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
|
||||||
) {
|
) {
|
||||||
$editMetadata = true;
|
editMetadata.current = true;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === 'p' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'p' && (e.metaKey || e.ctrlKey)) {
|
||||||
$elevationProfile = !$elevationProfile;
|
$elevationProfile = !$elevationProfile;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === 'l' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'l' && (e.metaKey || e.ctrlKey)) {
|
||||||
$verticalFileView = !$verticalFileView;
|
$treeFileView = !$treeFileView;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) {
|
||||||
if ($allHidden) {
|
if ($allHidden) {
|
||||||
dbUtils.setHiddenToSelection(false);
|
fileActions.setHiddenToSelection(false);
|
||||||
} else {
|
} else {
|
||||||
dbUtils.setHiddenToSelection(true);
|
fileActions.setHiddenToSelection(true);
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
if ($selection.size > 0) {
|
if ($selection.size > 0) {
|
||||||
centerMapOnSelection();
|
boundsManager.centerMapOnSelection();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'F1') {
|
} else if (e.key === 'F1') {
|
||||||
switchBasemaps();
|
switchBasemaps();
|
||||||
@@ -602,7 +651,10 @@
|
|||||||
e.key === 'ArrowUp'
|
e.key === 'ArrowUp'
|
||||||
) {
|
) {
|
||||||
if (!targetInput) {
|
if (!targetInput) {
|
||||||
updateSelectionFromKey(e.key === 'ArrowRight' || e.key === 'ArrowDown', e.shiftKey);
|
selection.updateFromKey(
|
||||||
|
e.key === 'ArrowRight' || e.key === 'ArrowDown',
|
||||||
|
e.shiftKey
|
||||||
|
);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -610,13 +662,15 @@
|
|||||||
on:dragover={(e) => e.preventDefault()}
|
on:dragover={(e) => e.preventDefault()}
|
||||||
on:drop={(e) => {
|
on:drop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.dataTransfer.files.length > 0) {
|
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
||||||
loadFiles(e.dataTransfer.files);
|
loadFiles(e.dataTransfer.files);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../app.css";
|
||||||
|
|
||||||
div :global(button) {
|
div :global(button) {
|
||||||
@apply hover:bg-accent;
|
@apply hover:bg-accent;
|
||||||
@apply px-3;
|
@apply px-3;
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Moon, Sun } from 'lucide-svelte';
|
import { Moon, Sun } from '@lucide/svelte';
|
||||||
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
export let size = '20';
|
let {
|
||||||
|
class: className = '',
|
||||||
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
|
}: {
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="h-8 px-1.5 {$$props.class ?? ''}"
|
size="icon"
|
||||||
on:click={() => {
|
class={className}
|
||||||
setMode(selectedMode === 'light' ? 'dark' : 'light');
|
onclick={() => {
|
||||||
|
setMode(mode.current === 'light' ? 'dark' : 'light');
|
||||||
}}
|
}}
|
||||||
aria-label={$_('menu.mode')}
|
aria-label={i18n._('menu.mode')}
|
||||||
>
|
>
|
||||||
{#if selectedMode === 'light'}
|
{#if mode.current === 'light'}
|
||||||
<Sun {size} />
|
<Sun />
|
||||||
{:else}
|
{:else}
|
||||||
<Moon {size} />
|
<Moon />
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,30 +3,43 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
|
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
|
||||||
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
||||||
import { BookOpenText, Home, Map } from 'lucide-svelte';
|
import { BookOpenText, House, Map } from '@lucide/svelte';
|
||||||
import { _, locale } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="w-full sticky top-0 bg-background z-50">
|
<nav class="w-full sticky top-0 bg-background z-50">
|
||||||
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
|
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
|
||||||
<a href={getURLForLanguage($locale, '/')} class="shrink-0 translate-y-0.5">
|
<a href={getURLForLanguage(i18n.lang, '/')} class="shrink-0 translate-y-0.5">
|
||||||
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
|
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
|
||||||
<Logo class="h-8 hidden sm:block" width="153" />
|
<Logo class="h-8 hidden sm:block" width="153" />
|
||||||
</a>
|
</a>
|
||||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/')}>
|
<Button
|
||||||
<Home size="18" class="mr-1.5" />
|
variant="link"
|
||||||
{$_('homepage.home')}
|
class="text-base px-0 has-[>svg]:px-0"
|
||||||
|
href={getURLForLanguage(i18n.lang, '/')}
|
||||||
|
>
|
||||||
|
<House size="18" />
|
||||||
|
{i18n._('homepage.home')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/app')}>
|
<Button
|
||||||
<Map size="18" class="mr-1.5" />
|
data-sveltekit-reload
|
||||||
{$_('homepage.app')}
|
variant="link"
|
||||||
|
class="text-base px-0 has-[>svg]:px-0"
|
||||||
|
href={getURLForLanguage(i18n.lang, '/app')}
|
||||||
|
>
|
||||||
|
<Map size="18" />
|
||||||
|
{i18n._('homepage.app')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/help')}>
|
<Button
|
||||||
<BookOpenText size="18" class="mr-1.5" />
|
variant="link"
|
||||||
{$_('menu.help')}
|
class="text-base px-0 has-[>svg]:px-0"
|
||||||
|
href={getURLForLanguage(i18n.lang, '/help')}
|
||||||
|
>
|
||||||
|
<BookOpenText size="18" />
|
||||||
|
{i18n._('menu.help')}
|
||||||
</Button>
|
</Button>
|
||||||
<AlgoliaDocSearch class="ml-auto" />
|
<AlgoliaDocSearch class="ml-auto" />
|
||||||
<ModeSwitch class="hidden xs:block" />
|
<ModeSwitch class="hidden xs:inline-flex" />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let orientation: 'col' | 'row' = 'col';
|
let {
|
||||||
|
orientation = 'col',
|
||||||
export let after: number;
|
after = $bindable(),
|
||||||
export let minAfter: number = 0;
|
minAfter = 0,
|
||||||
export let maxAfter: number = Number.MAX_SAFE_INTEGER;
|
maxAfter = Number.MAX_SAFE_INTEGER,
|
||||||
|
}: {
|
||||||
|
orientation?: 'col' | 'row';
|
||||||
|
after: number;
|
||||||
|
minAfter?: number;
|
||||||
|
maxAfter?: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
function handleMouseDown(event: PointerEvent) {
|
function handleMouseDown(event: PointerEvent) {
|
||||||
const startX = event.clientX;
|
const startX = event.clientX;
|
||||||
@@ -12,7 +18,8 @@
|
|||||||
|
|
||||||
const handleMouseMove = (event: PointerEvent) => {
|
const handleMouseMove = (event: PointerEvent) => {
|
||||||
const newAfter =
|
const newAfter =
|
||||||
startAfter + (orientation === 'col' ? startX - event.clientX : startY - event.clientY);
|
startAfter +
|
||||||
|
(orientation === 'col' ? startX - event.clientX : startY - event.clientY);
|
||||||
if (newAfter >= minAfter && newAfter <= maxAfter) {
|
if (newAfter >= minAfter && newAfter <= maxAfter) {
|
||||||
after = newAfter;
|
after = newAfter;
|
||||||
} else if (newAfter < minAfter && after !== minAfter) {
|
} else if (newAfter < minAfter && after !== minAfter) {
|
||||||
@@ -32,10 +39,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="{orientation === 'col'
|
class="{orientation === 'col'
|
||||||
? 'w-1 h-full cursor-col-resize border-l'
|
? 'w-1 h-full cursor-col-resize border-l'
|
||||||
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
|
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
|
||||||
on:pointerdown={handleMouseDown}
|
onpointerdown={handleMouseDown}
|
||||||
/>
|
></div>
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isMac, isSafari } from '$lib/utils';
|
import { isMac, isSafari } from '$lib/utils';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import * as Kbd from '$lib/components/ui/kbd/index.js';
|
||||||
|
|
||||||
export let key: string | undefined = undefined;
|
let {
|
||||||
export let shift: boolean = false;
|
key = undefined,
|
||||||
export let ctrl: boolean = false;
|
shift = false,
|
||||||
export let click: boolean = false;
|
ctrl = false,
|
||||||
|
click = false,
|
||||||
|
class: className = '',
|
||||||
|
}: {
|
||||||
|
key?: string;
|
||||||
|
shift?: boolean;
|
||||||
|
ctrl?: boolean;
|
||||||
|
click?: boolean;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let mac = false;
|
let mac = $state(false);
|
||||||
let safari = false;
|
let safari = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
mac = isMac();
|
mac = isMac();
|
||||||
@@ -17,20 +27,17 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<Kbd.Root class="ml-auto {className}">
|
||||||
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
|
|
||||||
{...$$props}
|
|
||||||
>
|
|
||||||
{#if shift}
|
{#if shift}
|
||||||
<span>⇧</span>
|
⇧
|
||||||
{/if}
|
{/if}
|
||||||
{#if ctrl}
|
{#if ctrl}
|
||||||
<span>{mac && !safari ? '⌘' : $_('menu.ctrl') + '+'}</span>
|
{mac && !safari ? '⌘' : i18n._('menu.ctrl')}
|
||||||
{/if}
|
{/if}
|
||||||
{#if key}
|
{#if key}
|
||||||
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
|
{key}
|
||||||
{/if}
|
{/if}
|
||||||
{#if click}
|
{#if click}
|
||||||
<span>{$_('menu.click')}</span>
|
{i18n._('menu.click')}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</Kbd.Root>
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
export let label: string;
|
let {
|
||||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
label,
|
||||||
|
side = 'top',
|
||||||
|
children,
|
||||||
|
extra,
|
||||||
|
class: className = '',
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
children: Snippet;
|
||||||
|
extra?: Snippet;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Tooltip.Root>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Trigger {...$$restProps} aria-label={label}>
|
<Tooltip.Root>
|
||||||
<slot />
|
<Tooltip.Trigger class={className} aria-label={label}>
|
||||||
|
{@render children()}
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content {side}>
|
<Tooltip.Content {side}>
|
||||||
<div class="flex flex-row items-center">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<slot name="extra" />
|
{@render extra?.()}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { settings } from '$lib/db';
|
|
||||||
import {
|
import {
|
||||||
celsiusToFahrenheit,
|
celsiusToFahrenheit,
|
||||||
getConvertedDistance,
|
getConvertedDistance,
|
||||||
@@ -8,20 +7,29 @@
|
|||||||
getDistanceUnits,
|
getDistanceUnits,
|
||||||
getElevationUnits,
|
getElevationUnits,
|
||||||
getVelocityUnits,
|
getVelocityUnits,
|
||||||
secondsToHHMMSS
|
secondsToHHMMSS,
|
||||||
} from '$lib/units';
|
} from '$lib/units';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
|
||||||
import { _ } from 'svelte-i18n';
|
let {
|
||||||
|
value,
|
||||||
export let value: number;
|
type,
|
||||||
export let type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
|
showUnits = true,
|
||||||
export let showUnits: boolean = true;
|
decimals = undefined,
|
||||||
export let decimals: number | undefined = undefined;
|
class: className = '',
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
|
||||||
|
showUnits?: boolean;
|
||||||
|
decimals?: number;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class={$$props.class}>
|
<span class={className}>
|
||||||
{#if type === 'distance'}
|
{#if type === 'distance'}
|
||||||
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
|
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
|
||||||
{showUnits ? getDistanceUnits($distanceUnits) : ''}
|
{showUnits ? getDistanceUnits($distanceUnits) : ''}
|
||||||
@@ -38,9 +46,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{:else if type === 'temperature'}
|
{:else if type === 'temperature'}
|
||||||
{#if $temperatureUnits === 'celsius'}
|
{#if $temperatureUnits === 'celsius'}
|
||||||
{value} {showUnits ? $_('units.celsius') : ''}
|
{value} {showUnits ? i18n._('units.celsius') : ''}
|
||||||
{:else}
|
{:else}
|
||||||
{celsiusToFahrenheit(value)} {showUnits ? $_('units.fahrenheit') : ''}
|
{celsiusToFahrenheit(value)} {showUnits ? i18n._('units.fahrenheit') : ''}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if type === 'time'}
|
{:else if type === 'time'}
|
||||||
{secondsToHHMMSS(value)}
|
{secondsToHHMMSS(value)}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { setContext } from 'svelte';
|
import { setContext, type Snippet } from 'svelte';
|
||||||
import { writable } from 'svelte/store';
|
import { CollapsibleTreeState } from './utils.svelte';
|
||||||
|
|
||||||
export let defaultState: 'open' | 'closed' = 'open';
|
const {
|
||||||
export let side: 'left' | 'right' = 'right';
|
defaultState = 'open',
|
||||||
export let nohover: boolean = false;
|
side = 'right',
|
||||||
export let slotInsideTrigger: boolean = true;
|
nohover = false,
|
||||||
|
slotInsideTrigger = true,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
defaultState?: 'open' | 'closed';
|
||||||
|
side?: 'left' | 'right';
|
||||||
|
nohover?: boolean;
|
||||||
|
slotInsideTrigger?: boolean;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let open = writable<Record<string, boolean>>({});
|
let open = $state(new CollapsibleTreeState(defaultState));
|
||||||
|
|
||||||
setContext('collapsible-tree-default-state', defaultState);
|
|
||||||
setContext('collapsible-tree-state', open);
|
setContext('collapsible-tree-state', open);
|
||||||
setContext('collapsible-tree-side', side);
|
setContext('collapsible-tree-side', side);
|
||||||
setContext('collapsible-tree-nohover', nohover);
|
setContext('collapsible-tree-nohover', nohover);
|
||||||
@@ -17,4 +25,4 @@
|
|||||||
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
|
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<slot />
|
{@render children()}
|
||||||
|
|||||||
@@ -1,60 +1,56 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
import { ChevronDown, ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||||
import { getContext, onMount, setContext } from 'svelte';
|
import { getContext, setContext, type Snippet } from 'svelte';
|
||||||
import { get, type Writable } from 'svelte/store';
|
import type { ClassValue } from 'svelte/elements';
|
||||||
|
import type { CollapsibleTreeState } from './utils.svelte';
|
||||||
|
|
||||||
export let id: string | number;
|
const props: {
|
||||||
|
id: string | number;
|
||||||
|
class?: ClassValue;
|
||||||
|
trigger: Snippet;
|
||||||
|
content: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let defaultState = getContext<'open' | 'closed'>('collapsible-tree-default-state');
|
let state = getContext<CollapsibleTreeState>('collapsible-tree-state');
|
||||||
let open = getContext<Writable<Record<string, boolean>>>('collapsible-tree-state');
|
|
||||||
let side = getContext<'left' | 'right'>('collapsible-tree-side');
|
let side = getContext<'left' | 'right'>('collapsible-tree-side');
|
||||||
let nohover = getContext<boolean>('collapsible-tree-nohover');
|
let nohover = getContext<boolean>('collapsible-tree-nohover');
|
||||||
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
|
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
|
||||||
let parentId = getContext<string>('collapsible-tree-parent-id');
|
let parentId = getContext<string>('collapsible-tree-parent-id');
|
||||||
|
|
||||||
let fullId = `${parentId}.${id}`;
|
let fullId = `${parentId}.${props.id}`;
|
||||||
setContext('collapsible-tree-parent-id', fullId);
|
setContext('collapsible-tree-parent-id', fullId);
|
||||||
|
|
||||||
onMount(() => {
|
let open = state.get(fullId);
|
||||||
if (!get(open).hasOwnProperty(fullId)) {
|
|
||||||
open.update((value) => {
|
|
||||||
value[fullId] = defaultState === 'open';
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export function openNode() {
|
export function openNode() {
|
||||||
open.update((value) => {
|
open.current = true;
|
||||||
value[fullId] = true;
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Collapsible.Root bind:open={$open[fullId]} class={$$props.class ?? ''}>
|
<Collapsible.Root bind:open={open.current} class={props.class}>
|
||||||
{#if slotInsideTrigger}
|
{#if slotInsideTrigger}
|
||||||
<Collapsible.Trigger class="w-full">
|
<Collapsible.Trigger class="w-full">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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-between'
|
||||||
: 'justify-start'} py-0 px-1 h-fit {nohover
|
: 'justify-start pl-1'} h-fit {nohover
|
||||||
? 'hover:bg-background'
|
? 'hover:bg-background'
|
||||||
: ''} pointer-events-none"
|
: ''} pointer-events-none"
|
||||||
>
|
>
|
||||||
{#if side === 'left'}
|
{#if side === 'left'}
|
||||||
{#if $open[fullId]}
|
{#if open.current}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronRight size="16" class="shrink-0" />
|
<ChevronRight size="16" class="shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<slot name="trigger" />
|
{@render props.trigger()}
|
||||||
{#if side === 'right'}
|
{#if side === 'right'}
|
||||||
{#if $open[fullId]}
|
{#if open.current}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronLeft size="16" class="shrink-0" />
|
<ChevronLeft size="16" class="shrink-0" />
|
||||||
@@ -65,23 +61,24 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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-between'
|
||||||
: 'justify-start'} py-0 px-1 h-fit {nohover ? 'hover:bg-background' : ''}"
|
: 'justify-start pl-1'} h-fit {nohover ? 'hover:bg-background' : ''}"
|
||||||
>
|
>
|
||||||
{#if side === 'left'}
|
{#if side === 'left'}
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
{#if $open[fullId]}
|
{#if open.current}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronRight size="16" class="shrink-0" />
|
<ChevronRight size="16" class="shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
{/if}
|
{/if}
|
||||||
<slot name="trigger" />
|
{@render props.trigger()}
|
||||||
{#if side === 'right'}
|
{#if side === 'right'}
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
{#if $open[fullId]}
|
{#if open.current}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronLeft size="16" class="shrink-0" />
|
<ChevronLeft size="16" class="shrink-0" />
|
||||||
@@ -90,8 +87,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<Collapsible.Content>
|
||||||
<Collapsible.Content class="ml-2">
|
{@render props.content()}
|
||||||
<slot name="content" />
|
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible.Root>
|
</Collapsible.Root>
|
||||||
|
|||||||
31
website/src/lib/components/collapsible-tree/utils.svelte.ts
Normal file
31
website/src/lib/components/collapsible-tree/utils.svelte.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export class CollapsibleNodeState {
|
||||||
|
private _open: boolean;
|
||||||
|
|
||||||
|
constructor(defaultState: 'open' | 'closed') {
|
||||||
|
this._open = $state(defaultState === 'open');
|
||||||
|
}
|
||||||
|
|
||||||
|
get current(): boolean {
|
||||||
|
return this._open;
|
||||||
|
}
|
||||||
|
|
||||||
|
set current(value: boolean) {
|
||||||
|
this._open = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CollapsibleTreeState {
|
||||||
|
private _open: Record<string, CollapsibleNodeState> = {};
|
||||||
|
private _defaultState: 'open' | 'closed';
|
||||||
|
|
||||||
|
constructor(defaultState: 'open' | 'closed') {
|
||||||
|
this._defaultState = defaultState;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): CollapsibleNodeState {
|
||||||
|
if (this._open[id] === undefined) {
|
||||||
|
this._open[id] = new CollapsibleNodeState(this._defaultState);
|
||||||
|
}
|
||||||
|
return this._open[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import CustomControl from './CustomControl';
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
|
|
||||||
export let position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' = 'top-right';
|
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
|
||||||
let control: CustomControl | undefined = undefined;
|
|
||||||
|
|
||||||
$: if ($map && container) {
|
|
||||||
if (position.includes('right')) container.classList.add('float-right');
|
|
||||||
else container.classList.add('float-left');
|
|
||||||
container.classList.remove('hidden');
|
|
||||||
if (control === undefined) {
|
|
||||||
control = new CustomControl(container);
|
|
||||||
}
|
|
||||||
$map.addControl(control, position);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={container}
|
|
||||||
class="{$$props.class ||
|
|
||||||
''} clear-both translate-0 m-[10px] mb-0 last:mb-[10px] pointer-events-auto bg-background rounded shadow-md hidden"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from 'svelte-i18n';
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
export let module;
|
let { module: Module }: { module: Component } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="markdown flex flex-col gap-3">
|
<div class="markdown flex flex-col gap-3">
|
||||||
<svelte:component this={module} />
|
<Module />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../../app.css";
|
||||||
|
|
||||||
:global(.markdown) {
|
:global(.markdown) {
|
||||||
@apply text-muted-foreground;
|
@apply text-muted-foreground;
|
||||||
}
|
}
|
||||||
@@ -43,6 +45,7 @@
|
|||||||
:global(.markdown > a) {
|
:global(.markdown > a) {
|
||||||
@apply text-link;
|
@apply text-link;
|
||||||
@apply hover:underline;
|
@apply hover:underline;
|
||||||
|
@apply contents;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.markdown p > a) {
|
:global(.markdown p > a) {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
|
let {
|
||||||
export let alt: string;
|
src,
|
||||||
|
alt,
|
||||||
|
}: {
|
||||||
|
src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
|
||||||
|
alt: string;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col items-center py-6 w-full">
|
<div class="flex flex-col items-center py-6 w-full">
|
||||||
@@ -18,7 +23,11 @@
|
|||||||
class="w-full max-w-3xl"
|
class="w-full max-w-3xl"
|
||||||
/>
|
/>
|
||||||
{:else if src === 'tools/split'}
|
{:else if src === 'tools/split'}
|
||||||
<enhanced:img src="/src/lib/assets/img/docs/tools/split.png" {alt} class="w-full max-w-3xl" />
|
<enhanced:img
|
||||||
|
src="/src/lib/assets/img/docs/tools/split.png"
|
||||||
|
{alt}
|
||||||
|
class="w-full max-w-3xl"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center text-sm text-muted-foreground mt-2">{alt}</p>
|
<p class="text-center text-sm text-muted-foreground mt-2">{alt}</p>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let type: 'note' | 'warning' = 'note';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { type = 'note', children }: { type?: 'note' | 'warning'; children: Snippet } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -7,10 +9,12 @@
|
|||||||
? 'border-link'
|
? 'border-link'
|
||||||
: 'border-destructive'} p-2 text-sm rounded-md"
|
: 'border-destructive'} p-2 text-sm rounded-md"
|
||||||
>
|
>
|
||||||
<slot />
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../../app.css";
|
||||||
|
|
||||||
div :global(a) {
|
div :global(a) {
|
||||||
@apply text-link;
|
@apply text-link;
|
||||||
@apply hover:underline;
|
@apply hover:underline;
|
||||||
|
|||||||
@@ -1,39 +1,64 @@
|
|||||||
import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer, MountainSnow } from "lucide-svelte";
|
import {
|
||||||
import type { ComponentType } from "svelte";
|
File,
|
||||||
|
FilePen,
|
||||||
|
View,
|
||||||
|
Settings,
|
||||||
|
Pencil,
|
||||||
|
MapPin,
|
||||||
|
Scissors,
|
||||||
|
CalendarClock,
|
||||||
|
Group,
|
||||||
|
Ungroup,
|
||||||
|
Funnel,
|
||||||
|
SquareDashedMousePointer,
|
||||||
|
MountainSnow,
|
||||||
|
type IconProps,
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
export const guides: Record<string, string[]> = {
|
export const guides: Record<string, string[]> = {
|
||||||
'getting-started': [],
|
'getting-started': [],
|
||||||
menu: ['file', 'edit', 'view', 'settings'],
|
menu: ['file', 'edit', 'view', 'settings'],
|
||||||
'files-and-stats': [],
|
'files-and-stats': [],
|
||||||
toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'elevation', 'minify', 'clean'],
|
toolbar: [
|
||||||
|
'routing',
|
||||||
|
'poi',
|
||||||
|
'scissors',
|
||||||
|
'time',
|
||||||
|
'merge',
|
||||||
|
'extract',
|
||||||
|
'elevation',
|
||||||
|
'minify',
|
||||||
|
'clean',
|
||||||
|
],
|
||||||
'map-controls': [],
|
'map-controls': [],
|
||||||
'gpx': [],
|
gpx: [],
|
||||||
'integration': [],
|
integration: [],
|
||||||
'faq': [],
|
faq: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
|
export const guideIcons: Record<string, string | Component<IconProps>> = {
|
||||||
"getting-started": "🚀",
|
'getting-started': '🚀',
|
||||||
"menu": "📂 ⚙️",
|
menu: '📂 ⚙️',
|
||||||
"file": File,
|
file: File,
|
||||||
"edit": FilePen,
|
edit: FilePen,
|
||||||
"view": View,
|
view: View,
|
||||||
"settings": Settings,
|
settings: Settings,
|
||||||
"files-and-stats": "🗂 📈",
|
'files-and-stats': '🗂 📈',
|
||||||
"toolbar": "🧰",
|
toolbar: '🧰',
|
||||||
"routing": Pencil,
|
routing: Pencil,
|
||||||
"poi": MapPin,
|
poi: MapPin,
|
||||||
"scissors": Scissors,
|
scissors: Scissors,
|
||||||
"time": CalendarClock,
|
time: CalendarClock,
|
||||||
"merge": Group,
|
merge: Group,
|
||||||
"extract": Ungroup,
|
extract: Ungroup,
|
||||||
"elevation": MountainSnow,
|
elevation: MountainSnow,
|
||||||
"minify": Filter,
|
minify: Funnel,
|
||||||
"clean": SquareDashedMousePointer,
|
clean: SquareDashedMousePointer,
|
||||||
"map-controls": "🗺",
|
'map-controls': '🗺',
|
||||||
"gpx": "💾",
|
gpx: '💾',
|
||||||
"integration": "{ 👩💻 }",
|
integration: '{ 👩💻 }',
|
||||||
"faq": "🔮",
|
faq: '🔮',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getPreviousGuide(currentGuide: string): string | undefined {
|
export function getPreviousGuide(currentGuide: string): string | undefined {
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
|
||||||
|
import * as Popover from '$lib/components/ui/popover/index.js';
|
||||||
|
import * as ToggleGroup from '$lib/components/ui/toggle-group/index.js';
|
||||||
|
import Separator from '$lib/components/ui/separator/separator.svelte';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
BrickWall,
|
||||||
|
TriangleRight,
|
||||||
|
HeartPulse,
|
||||||
|
Orbit,
|
||||||
|
SquareActivity,
|
||||||
|
Thermometer,
|
||||||
|
Zap,
|
||||||
|
Circle,
|
||||||
|
Check,
|
||||||
|
ChartNoAxesColumn,
|
||||||
|
Construction,
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import type { Readable, Writable } from 'svelte/store';
|
||||||
|
import type { GPXStatistics } from 'gpx';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
|
||||||
|
|
||||||
|
const { velocityUnits } = settings;
|
||||||
|
|
||||||
|
let {
|
||||||
|
gpxStatistics,
|
||||||
|
slicedGPXStatistics,
|
||||||
|
additionalDatasets,
|
||||||
|
elevationFill,
|
||||||
|
showControls = true,
|
||||||
|
}: {
|
||||||
|
gpxStatistics: Readable<GPXStatistics>;
|
||||||
|
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||||
|
additionalDatasets: Writable<string[]>;
|
||||||
|
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
||||||
|
showControls?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement;
|
||||||
|
let overlay: HTMLCanvasElement;
|
||||||
|
let elevationProfile: ElevationProfile | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
elevationProfile = new ElevationProfile(
|
||||||
|
gpxStatistics,
|
||||||
|
slicedGPXStatistics,
|
||||||
|
additionalDatasets,
|
||||||
|
elevationFill,
|
||||||
|
canvas,
|
||||||
|
overlay
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (elevationProfile) {
|
||||||
|
elevationProfile.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-full grow min-w-0 relative py-2">
|
||||||
|
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas>
|
||||||
|
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas>
|
||||||
|
{#if showControls}
|
||||||
|
<div class="absolute bottom-10 right-1.5">
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger>
|
||||||
|
<ButtonWithTooltip
|
||||||
|
label={i18n._('chart.settings')}
|
||||||
|
variant="outline"
|
||||||
|
side="left"
|
||||||
|
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 hover:bg-background"
|
||||||
|
>
|
||||||
|
<ChartNoAxesColumn size="18" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content
|
||||||
|
class="w-fit p-0 flex flex-col"
|
||||||
|
side="top"
|
||||||
|
align="end"
|
||||||
|
sideOffset={-32}
|
||||||
|
>
|
||||||
|
<ToggleGroup.Root
|
||||||
|
class="flex flex-col items-start gap-0 p-1 w-full border-none"
|
||||||
|
type="single"
|
||||||
|
bind:value={$elevationFill}
|
||||||
|
>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
|
value="slope"
|
||||||
|
>
|
||||||
|
<div class="w-6 flex justify-center items-center">
|
||||||
|
{#if $elevationFill === 'slope'}
|
||||||
|
<Circle class="size-1.5 fill-current text-current" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<TriangleRight size="15" />
|
||||||
|
{i18n._('quantities.slope')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
|
value="surface"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<div class="w-6 flex justify-center items-center">
|
||||||
|
{#if $elevationFill === 'surface'}
|
||||||
|
<Circle class="size-1.5 fill-current text-current" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<BrickWall size="15" />
|
||||||
|
{i18n._('quantities.surface')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
|
value="highway"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<div class="w-6 flex justify-center items-center">
|
||||||
|
{#if $elevationFill === 'highway'}
|
||||||
|
<Circle class="size-1.5 fill-current text-current" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Construction size="15" />
|
||||||
|
{i18n._('quantities.highway')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
</ToggleGroup.Root>
|
||||||
|
<Separator />
|
||||||
|
<ToggleGroup.Root
|
||||||
|
class="flex flex-col items-start gap-0 p-1"
|
||||||
|
type="multiple"
|
||||||
|
bind:value={$additionalDatasets}
|
||||||
|
>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
|
value="speed"
|
||||||
|
>
|
||||||
|
<div class="w-6 flex justify-center items-center">
|
||||||
|
{#if $additionalDatasets.includes('speed')}
|
||||||
|
<Check size="14" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Zap size="15" />
|
||||||
|
{$velocityUnits === 'speed'
|
||||||
|
? i18n._('quantities.speed')
|
||||||
|
: i18n._('quantities.pace')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
|
value="hr"
|
||||||
|
>
|
||||||
|
<div class="w-6 flex justify-center items-center">
|
||||||
|
{#if $additionalDatasets.includes('hr')}
|
||||||
|
<Check size="14" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<HeartPulse size="15" />
|
||||||
|
{i18n._('quantities.heartrate')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
|
value="cad"
|
||||||
|
>
|
||||||
|
<div class="w-6 flex justify-center items-center">
|
||||||
|
{#if $additionalDatasets.includes('cad')}
|
||||||
|
<Check size="14" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Orbit size="15" />
|
||||||
|
{i18n._('quantities.cadence')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
|
value="atemp"
|
||||||
|
>
|
||||||
|
<div class="w-6 flex justify-center items-center">
|
||||||
|
{#if $additionalDatasets.includes('atemp')}
|
||||||
|
<Check size="14" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Thermometer size="15" />
|
||||||
|
{i18n._('quantities.temperature')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
|
value="power"
|
||||||
|
>
|
||||||
|
<div class="w-6 flex justify-center items-center">
|
||||||
|
{#if $additionalDatasets.includes('power')}
|
||||||
|
<Check size="14" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<SquareActivity size="15" />
|
||||||
|
{i18n._('quantities.power')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
</ToggleGroup.Root>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import {
|
||||||
|
getCadenceWithUnits,
|
||||||
|
getConvertedDistance,
|
||||||
|
getConvertedElevation,
|
||||||
|
getConvertedTemperature,
|
||||||
|
getConvertedVelocity,
|
||||||
|
getDistanceUnits,
|
||||||
|
getDistanceWithUnits,
|
||||||
|
getElevationWithUnits,
|
||||||
|
getHeartRateWithUnits,
|
||||||
|
getPowerWithUnits,
|
||||||
|
getTemperatureWithUnits,
|
||||||
|
getVelocityWithUnits,
|
||||||
|
} from '$lib/units';
|
||||||
|
import Chart from 'chart.js/auto';
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import { get, type Readable, type Writable } from 'svelte/store';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
import type { GPXStatistics } from 'gpx';
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
|
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
|
||||||
|
|
||||||
|
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||||
|
|
||||||
|
Chart.defaults.font.family =
|
||||||
|
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
|
||||||
|
|
||||||
|
export class ElevationProfile {
|
||||||
|
private _chart: Chart | null = null;
|
||||||
|
private _canvas: HTMLCanvasElement;
|
||||||
|
private _overlay: HTMLCanvasElement;
|
||||||
|
private _marker: mapboxgl.Marker | null = null;
|
||||||
|
private _dragging = false;
|
||||||
|
private _panning = false;
|
||||||
|
|
||||||
|
private _gpxStatistics: Readable<GPXStatistics>;
|
||||||
|
private _slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||||
|
private _additionalDatasets: Readable<string[]>;
|
||||||
|
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
gpxStatistics: Readable<GPXStatistics>,
|
||||||
|
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>,
|
||||||
|
additionalDatasets: Readable<string[]>,
|
||||||
|
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
overlay: HTMLCanvasElement
|
||||||
|
) {
|
||||||
|
this._gpxStatistics = gpxStatistics;
|
||||||
|
this._slicedGPXStatistics = slicedGPXStatistics;
|
||||||
|
this._additionalDatasets = additionalDatasets;
|
||||||
|
this._elevationFill = elevationFill;
|
||||||
|
this._canvas = canvas;
|
||||||
|
this._overlay = overlay;
|
||||||
|
|
||||||
|
let element = document.createElement('div');
|
||||||
|
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
|
||||||
|
this._marker = new mapboxgl.Marker({
|
||||||
|
element,
|
||||||
|
});
|
||||||
|
|
||||||
|
import('chartjs-plugin-zoom').then((module) => {
|
||||||
|
Chart.register(module.default);
|
||||||
|
this.initialize();
|
||||||
|
|
||||||
|
this._gpxStatistics.subscribe(() => {
|
||||||
|
this.updateData();
|
||||||
|
});
|
||||||
|
this._slicedGPXStatistics.subscribe(() => {
|
||||||
|
this.updateOverlay();
|
||||||
|
});
|
||||||
|
distanceUnits.subscribe(() => {
|
||||||
|
this.updateData();
|
||||||
|
});
|
||||||
|
velocityUnits.subscribe(() => {
|
||||||
|
this.updateData();
|
||||||
|
});
|
||||||
|
temperatureUnits.subscribe(() => {
|
||||||
|
this.updateData();
|
||||||
|
});
|
||||||
|
this._additionalDatasets.subscribe(() => {
|
||||||
|
this.updateDataVisibility();
|
||||||
|
});
|
||||||
|
this._elevationFill.subscribe(() => {
|
||||||
|
this.updateFill();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize() {
|
||||||
|
let options = {
|
||||||
|
animation: false,
|
||||||
|
parsing: false,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'linear',
|
||||||
|
ticks: {
|
||||||
|
callback: function (value: number) {
|
||||||
|
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
|
||||||
|
},
|
||||||
|
align: 'inner',
|
||||||
|
maxRotation: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
ticks: {
|
||||||
|
callback: function (value: number) {
|
||||||
|
return getElevationWithUnits(value, false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
datasets: {
|
||||||
|
line: {
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.4,
|
||||||
|
borderWidth: 2,
|
||||||
|
cubicInterpolationMode: 'monotone',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
decimation: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: () => !this._dragging && !this._panning,
|
||||||
|
callbacks: {
|
||||||
|
title: () => {
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
label: (context: Chart.TooltipContext) => {
|
||||||
|
let point = context.raw;
|
||||||
|
if (context.datasetIndex === 0) {
|
||||||
|
const map_ = get(map);
|
||||||
|
if (map_ && this._marker) {
|
||||||
|
if (this._dragging) {
|
||||||
|
this._marker.remove();
|
||||||
|
} else {
|
||||||
|
this._marker.setLngLat(point.coordinates);
|
||||||
|
this._marker.addTo(map_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
||||||
|
} else if (context.datasetIndex === 1) {
|
||||||
|
return `${get(velocityUnits) === 'speed' ? i18n._('quantities.speed') : i18n._('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
|
||||||
|
} else if (context.datasetIndex === 2) {
|
||||||
|
return `${i18n._('quantities.heartrate')}: ${getHeartRateWithUnits(point.y)}`;
|
||||||
|
} else if (context.datasetIndex === 3) {
|
||||||
|
return `${i18n._('quantities.cadence')}: ${getCadenceWithUnits(point.y)}`;
|
||||||
|
} else if (context.datasetIndex === 4) {
|
||||||
|
return `${i18n._('quantities.temperature')}: ${getTemperatureWithUnits(point.y, false)}`;
|
||||||
|
} else if (context.datasetIndex === 5) {
|
||||||
|
return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
afterBody: (contexts: Chart.TooltipContext[]) => {
|
||||||
|
let context = contexts.filter((context) => context.datasetIndex === 0);
|
||||||
|
if (context.length === 0) return;
|
||||||
|
let point = context[0].raw;
|
||||||
|
let slope = {
|
||||||
|
at: point.slope.at.toFixed(1),
|
||||||
|
segment: point.slope.segment.toFixed(1),
|
||||||
|
length: getDistanceWithUnits(point.slope.length),
|
||||||
|
};
|
||||||
|
let surface = point.extensions.surface
|
||||||
|
? point.extensions.surface
|
||||||
|
: 'unknown';
|
||||||
|
let highway = point.extensions.highway
|
||||||
|
? point.extensions.highway
|
||||||
|
: 'unknown';
|
||||||
|
let sacScale = point.extensions.sac_scale;
|
||||||
|
let mtbScale = point.extensions.mtb_scale;
|
||||||
|
|
||||||
|
let labels = [
|
||||||
|
` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
|
||||||
|
` ${i18n._('quantities.slope')}: ${slope.at} %${get(this._elevationFill) === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (get(this._elevationFill) === 'surface') {
|
||||||
|
labels.push(
|
||||||
|
` ${i18n._('quantities.surface')}: ${i18n._(`toolbar.routing.surface.${surface}`)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (get(this._elevationFill) === 'highway') {
|
||||||
|
labels.push(
|
||||||
|
` ${i18n._('quantities.highway')}: ${i18n._(`toolbar.routing.highway.${highway}`)}${
|
||||||
|
sacScale
|
||||||
|
? ` (${i18n._(`toolbar.routing.sac_scale.${sacScale}`)})`
|
||||||
|
: ''
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
if (mtbScale) {
|
||||||
|
labels.push(
|
||||||
|
` ${i18n._('toolbar.routing.mtb_scale')}: ${mtbScale}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (point.time) {
|
||||||
|
labels.push(
|
||||||
|
` ${i18n._('quantities.time')}: ${i18n.df.format(point.time)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
pan: {
|
||||||
|
enabled: true,
|
||||||
|
mode: 'x',
|
||||||
|
modifierKey: 'shift',
|
||||||
|
onPanStart: () => {
|
||||||
|
this._panning = true;
|
||||||
|
this._slicedGPXStatistics.set(undefined);
|
||||||
|
},
|
||||||
|
onPanComplete: () => {
|
||||||
|
this._panning = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
wheel: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
mode: 'x',
|
||||||
|
onZoomStart: ({ chart, event }: { chart: Chart; event: any }) => {
|
||||||
|
if (
|
||||||
|
event.deltaY < 0 &&
|
||||||
|
Math.abs(
|
||||||
|
this._chart.getInitialScaleBounds().x.max /
|
||||||
|
this._chart.options.plugins.zoom.limits.x.minRange -
|
||||||
|
this._chart.getZoomLevel()
|
||||||
|
) < 0.01
|
||||||
|
) {
|
||||||
|
// Disable wheel pan if zoomed in to the max, and zooming in
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._slicedGPXStatistics.set(undefined);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
x: {
|
||||||
|
min: 'original',
|
||||||
|
max: 'original',
|
||||||
|
minRange: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stacked: false,
|
||||||
|
onResize: () => {
|
||||||
|
this.updateOverlay();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
|
||||||
|
datasets.forEach((id) => {
|
||||||
|
options.scales[`y${id}`] = {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'right',
|
||||||
|
grid: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
reverse: () => id === 'speed' && get(velocityUnits) === 'pace',
|
||||||
|
display: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this._chart = new Chart(this._canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
datasets: [],
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: 'toggleMarker',
|
||||||
|
events: ['mouseout'],
|
||||||
|
afterEvent: (chart: Chart, args: { event: Chart.ChartEvent }) => {
|
||||||
|
if (args.event.type === 'mouseout') {
|
||||||
|
const map_ = get(map);
|
||||||
|
if (map_ && this._marker) {
|
||||||
|
this._marker.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let startIndex = 0;
|
||||||
|
let endIndex = 0;
|
||||||
|
const getIndex = (evt) => {
|
||||||
|
if (!this._chart) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const points = this._chart.getElementsAtEventForMode(
|
||||||
|
evt,
|
||||||
|
'x',
|
||||||
|
{
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
const rect = this._canvas.getBoundingClientRect();
|
||||||
|
if (evt.x - rect.left <= this._chart.chartArea.left) {
|
||||||
|
return 0;
|
||||||
|
} else if (evt.x - rect.left >= this._chart.chartArea.right) {
|
||||||
|
return get(this._gpxStatistics).local.points.length - 1;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let point = points.find((point) => point.element.raw);
|
||||||
|
if (point) {
|
||||||
|
return point.element.raw.index;
|
||||||
|
} else {
|
||||||
|
return points[0].index;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let dragStarted = false;
|
||||||
|
const onMouseDown = (evt) => {
|
||||||
|
if (evt.shiftKey) {
|
||||||
|
// Panning interaction
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragStarted = true;
|
||||||
|
this._canvas.style.cursor = 'col-resize';
|
||||||
|
startIndex = getIndex(evt);
|
||||||
|
};
|
||||||
|
const onMouseMove = (evt) => {
|
||||||
|
if (dragStarted) {
|
||||||
|
this._dragging = true;
|
||||||
|
endIndex = getIndex(evt);
|
||||||
|
if (endIndex !== undefined) {
|
||||||
|
if (startIndex === undefined) {
|
||||||
|
startIndex = endIndex;
|
||||||
|
} else if (startIndex !== endIndex) {
|
||||||
|
this._slicedGPXStatistics.set([
|
||||||
|
get(this._gpxStatistics).slice(
|
||||||
|
Math.min(startIndex, endIndex),
|
||||||
|
Math.max(startIndex, endIndex)
|
||||||
|
),
|
||||||
|
Math.min(startIndex, endIndex),
|
||||||
|
Math.max(startIndex, endIndex),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onMouseUp = (evt) => {
|
||||||
|
dragStarted = false;
|
||||||
|
this._dragging = false;
|
||||||
|
this._canvas.style.cursor = '';
|
||||||
|
endIndex = getIndex(evt);
|
||||||
|
if (startIndex === endIndex) {
|
||||||
|
this._slicedGPXStatistics.set(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._canvas.addEventListener('pointerdown', onMouseDown);
|
||||||
|
this._canvas.addEventListener('pointermove', onMouseMove);
|
||||||
|
this._canvas.addEventListener('pointerup', onMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData() {
|
||||||
|
if (!this._chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = get(this._gpxStatistics);
|
||||||
|
this._chart.data.datasets[0] = {
|
||||||
|
label: i18n._('quantities.elevation'),
|
||||||
|
data: data.local.points.map((point, index) => {
|
||||||
|
return {
|
||||||
|
x: getConvertedDistance(data.local.distance.total[index]),
|
||||||
|
y: point.ele ? getConvertedElevation(point.ele) : 0,
|
||||||
|
time: point.time,
|
||||||
|
slope: {
|
||||||
|
at: data.local.slope.at[index],
|
||||||
|
segment: data.local.slope.segment[index],
|
||||||
|
length: data.local.slope.length[index],
|
||||||
|
},
|
||||||
|
extensions: point.getExtensions(),
|
||||||
|
coordinates: point.getCoordinates(),
|
||||||
|
index: index,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
normalized: true,
|
||||||
|
fill: 'start',
|
||||||
|
order: 1,
|
||||||
|
segment: {},
|
||||||
|
};
|
||||||
|
this._chart.data.datasets[1] = {
|
||||||
|
data: data.local.points.map((point, index) => {
|
||||||
|
return {
|
||||||
|
x: getConvertedDistance(data.local.distance.total[index]),
|
||||||
|
y: getConvertedVelocity(data.local.speed[index]),
|
||||||
|
index: index,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
normalized: true,
|
||||||
|
yAxisID: 'yspeed',
|
||||||
|
};
|
||||||
|
this._chart.data.datasets[2] = {
|
||||||
|
data: data.local.points.map((point, index) => {
|
||||||
|
return {
|
||||||
|
x: getConvertedDistance(data.local.distance.total[index]),
|
||||||
|
y: point.getHeartRate(),
|
||||||
|
index: index,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
normalized: true,
|
||||||
|
yAxisID: 'yhr',
|
||||||
|
};
|
||||||
|
this._chart.data.datasets[3] = {
|
||||||
|
data: data.local.points.map((point, index) => {
|
||||||
|
return {
|
||||||
|
x: getConvertedDistance(data.local.distance.total[index]),
|
||||||
|
y: point.getCadence(),
|
||||||
|
index: index,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
normalized: true,
|
||||||
|
yAxisID: 'ycad',
|
||||||
|
};
|
||||||
|
this._chart.data.datasets[4] = {
|
||||||
|
data: data.local.points.map((point, index) => {
|
||||||
|
return {
|
||||||
|
x: getConvertedDistance(data.local.distance.total[index]),
|
||||||
|
y: getConvertedTemperature(point.getTemperature()),
|
||||||
|
index: index,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
normalized: true,
|
||||||
|
yAxisID: 'yatemp',
|
||||||
|
};
|
||||||
|
this._chart.data.datasets[5] = {
|
||||||
|
data: data.local.points.map((point, index) => {
|
||||||
|
return {
|
||||||
|
x: getConvertedDistance(data.local.distance.total[index]),
|
||||||
|
y: point.getPower(),
|
||||||
|
index: index,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
normalized: true,
|
||||||
|
yAxisID: 'ypower',
|
||||||
|
};
|
||||||
|
this._chart.options.scales.x['min'] = 0;
|
||||||
|
this._chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
|
||||||
|
|
||||||
|
this.setVisibility();
|
||||||
|
this.setFill();
|
||||||
|
|
||||||
|
this._chart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDataVisibility() {
|
||||||
|
if (!this._chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setVisibility();
|
||||||
|
this._chart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibility() {
|
||||||
|
if (!this._chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const additionalDatasets = get(this._additionalDatasets);
|
||||||
|
let includeSpeed = additionalDatasets.includes('speed');
|
||||||
|
let includeHeartRate = additionalDatasets.includes('hr');
|
||||||
|
let includeCadence = additionalDatasets.includes('cad');
|
||||||
|
let includeTemperature = additionalDatasets.includes('atemp');
|
||||||
|
let includePower = additionalDatasets.includes('power');
|
||||||
|
if (this._chart.data.datasets.length == 6) {
|
||||||
|
this._chart.data.datasets[1].hidden = !includeSpeed;
|
||||||
|
this._chart.data.datasets[2].hidden = !includeHeartRate;
|
||||||
|
this._chart.data.datasets[3].hidden = !includeCadence;
|
||||||
|
this._chart.data.datasets[4].hidden = !includeTemperature;
|
||||||
|
this._chart.data.datasets[5].hidden = !includePower;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFill() {
|
||||||
|
if (!this._chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setFill();
|
||||||
|
this._chart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
setFill() {
|
||||||
|
if (!this._chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elevationFill = get(this._elevationFill);
|
||||||
|
if (elevationFill === 'slope') {
|
||||||
|
this._chart.data.datasets[0]['segment'] = {
|
||||||
|
backgroundColor: this.slopeFillCallback,
|
||||||
|
};
|
||||||
|
} else if (elevationFill === 'surface') {
|
||||||
|
this._chart.data.datasets[0]['segment'] = {
|
||||||
|
backgroundColor: this.surfaceFillCallback,
|
||||||
|
};
|
||||||
|
} else if (elevationFill === 'highway') {
|
||||||
|
this._chart.data.datasets[0]['segment'] = {
|
||||||
|
backgroundColor: this.highwayFillCallback,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this._chart.data.datasets[0]['segment'] = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOverlay() {
|
||||||
|
if (!this._chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._overlay.width = this._canvas.width / window.devicePixelRatio;
|
||||||
|
this._overlay.height = this._canvas.height / window.devicePixelRatio;
|
||||||
|
this._overlay.style.width = `${this._overlay.width}px`;
|
||||||
|
this._overlay.style.height = `${this._overlay.height}px`;
|
||||||
|
|
||||||
|
const slicedGPXStatistics = get(this._slicedGPXStatistics);
|
||||||
|
if (slicedGPXStatistics) {
|
||||||
|
let startIndex = slicedGPXStatistics[1];
|
||||||
|
let endIndex = slicedGPXStatistics[2];
|
||||||
|
|
||||||
|
// Draw selection rectangle
|
||||||
|
let selectionContext = this._overlay.getContext('2d');
|
||||||
|
if (selectionContext) {
|
||||||
|
selectionContext.fillStyle = mode.current === 'dark' ? 'white' : 'black';
|
||||||
|
selectionContext.globalAlpha = mode.current === 'dark' ? 0.2 : 0.1;
|
||||||
|
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
|
||||||
|
|
||||||
|
const gpxStatistics = get(this._gpxStatistics);
|
||||||
|
let startPixel = this._chart.scales.x.getPixelForValue(
|
||||||
|
getConvertedDistance(gpxStatistics.local.distance.total[startIndex])
|
||||||
|
);
|
||||||
|
let endPixel = this._chart.scales.x.getPixelForValue(
|
||||||
|
getConvertedDistance(gpxStatistics.local.distance.total[endIndex])
|
||||||
|
);
|
||||||
|
|
||||||
|
selectionContext.fillRect(
|
||||||
|
startPixel,
|
||||||
|
this._chart.chartArea.top,
|
||||||
|
endPixel - startPixel,
|
||||||
|
this._chart.chartArea.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (this._overlay) {
|
||||||
|
let selectionContext = this._overlay.getContext('2d');
|
||||||
|
if (selectionContext) {
|
||||||
|
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slopeFillCallback(context) {
|
||||||
|
return getSlopeColor(context.p0.raw.slope.segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
surfaceFillCallback(context) {
|
||||||
|
return getSurfaceColor(context.p0.raw.extensions.surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
highwayFillCallback(context) {
|
||||||
|
return getHighwayColor(
|
||||||
|
context.p0.raw.extensions.highway,
|
||||||
|
context.p0.raw.extensions.sac_scale,
|
||||||
|
context.p0.raw.extensions.mtb_scale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this._chart) {
|
||||||
|
this._chart.destroy();
|
||||||
|
this._chart = null;
|
||||||
|
}
|
||||||
|
if (this._marker) {
|
||||||
|
this._marker.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,238 +1,116 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import GPXLayers from '$lib/components/gpx-layer/GPXLayers.svelte';
|
import GPXLayers from '$lib/components/map/gpx-layer/GPXLayers.svelte';
|
||||||
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
|
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||||
import FileList from '$lib/components/file-list/FileList.svelte';
|
import FileList from '$lib/components/file-list/FileList.svelte';
|
||||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||||
import Map from '$lib/components/Map.svelte';
|
import Map from '$lib/components/map/Map.svelte';
|
||||||
import LayerControl from '$lib/components/layer-control/LayerControl.svelte';
|
import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
|
||||||
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
|
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
|
||||||
import {
|
import { writable } from 'svelte/store';
|
||||||
gpxStatistics,
|
|
||||||
slicedGPXStatistics,
|
|
||||||
embedding,
|
|
||||||
loadFile,
|
|
||||||
map,
|
|
||||||
updateGPXData
|
|
||||||
} from '$lib/stores';
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
import { fileObservers, settings, GPXStatisticsTree } from '$lib/db';
|
|
||||||
import { readable } from 'svelte/store';
|
|
||||||
import type { GPXFile } from 'gpx';
|
import type { GPXFile } from 'gpx';
|
||||||
import { selection } from '$lib/components/file-list/Selection';
|
|
||||||
import { ListFileItem } from '$lib/components/file-list/FileList';
|
|
||||||
import {
|
import {
|
||||||
allowedEmbeddingBasemaps,
|
allowedEmbeddingBasemaps,
|
||||||
getFilesFromEmbeddingOptions,
|
getFilesFromEmbeddingOptions,
|
||||||
type EmbeddingOptions
|
type EmbeddingOptions,
|
||||||
} from './Embedding';
|
} from './embedding';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { setMode } from 'mode-watcher';
|
||||||
import { browser } from '$app/environment';
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||||
|
import { loadFile } from '$lib/logic/file-actions';
|
||||||
|
import { selection } from '$lib/logic/selection';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||||
|
|
||||||
$embedding = true;
|
let {
|
||||||
|
useHash = true,
|
||||||
|
options = $bindable(),
|
||||||
|
hash = $bindable(),
|
||||||
|
}: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props();
|
||||||
|
|
||||||
|
let additionalDatasets = writable<string[]>([]);
|
||||||
|
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentBasemap,
|
currentBasemap,
|
||||||
|
selectedBasemapTree,
|
||||||
distanceUnits,
|
distanceUnits,
|
||||||
velocityUnits,
|
velocityUnits,
|
||||||
temperatureUnits,
|
temperatureUnits,
|
||||||
fileOrder,
|
fileOrder,
|
||||||
distanceMarkers,
|
distanceMarkers,
|
||||||
directionMarkers
|
directionMarkers,
|
||||||
} = settings;
|
} = settings;
|
||||||
|
|
||||||
export let useHash = true;
|
settings.initialize();
|
||||||
export let options: EmbeddingOptions;
|
|
||||||
export let hash: string;
|
|
||||||
|
|
||||||
let prevSettings = {
|
|
||||||
distanceMarkers: false,
|
|
||||||
directionMarkers: false,
|
|
||||||
distanceUnits: 'metric',
|
|
||||||
velocityUnits: 'speed',
|
|
||||||
temperatureUnits: 'celsius',
|
|
||||||
theme: 'system'
|
|
||||||
};
|
|
||||||
|
|
||||||
function applyOptions() {
|
function applyOptions() {
|
||||||
fileObservers.update(($fileObservers) => {
|
let downloads: Promise<GPXFile | null>[] = getFilesFromEmbeddingOptions(options).map(
|
||||||
$fileObservers.clear();
|
(url) => {
|
||||||
return $fileObservers;
|
return fetch(url)
|
||||||
});
|
|
||||||
|
|
||||||
let downloads: Promise<GPXFile | null>[] = [];
|
|
||||||
getFilesFromEmbeddingOptions(options).forEach((url) => {
|
|
||||||
downloads.push(
|
|
||||||
fetch(url)
|
|
||||||
.then((response) => response.blob())
|
.then((response) => response.blob())
|
||||||
.then((blob) => new File([blob], url.split('/').pop() ?? url))
|
.then((blob) => new File([blob], url.split('/').pop() ?? url))
|
||||||
.then(loadFile)
|
.then(loadFile);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
});
|
Promise.all(downloads).then((answers) => {
|
||||||
|
const files = answers.filter((file) => file !== null) as GPXFile[];
|
||||||
Promise.all(downloads).then((files) => {
|
|
||||||
let ids: string[] = [];
|
let ids: string[] = [];
|
||||||
let bounds = {
|
|
||||||
southWest: {
|
|
||||||
lat: 90,
|
|
||||||
lon: 180
|
|
||||||
},
|
|
||||||
northEast: {
|
|
||||||
lat: -90,
|
|
||||||
lon: -180
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fileObservers.update(($fileObservers) => {
|
|
||||||
files.forEach((file, index) => {
|
files.forEach((file, index) => {
|
||||||
if (file === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = `gpx-${index}-embed`;
|
let id = `gpx-${index}-embed`;
|
||||||
file._data.id = id;
|
file._data.id = id;
|
||||||
let statistics = new GPXStatisticsTree(file);
|
|
||||||
|
|
||||||
$fileObservers.set(
|
|
||||||
id,
|
|
||||||
readable({
|
|
||||||
file,
|
|
||||||
statistics
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
ids.push(id);
|
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);
|
|
||||||
});
|
});
|
||||||
|
fileStateCollection.setEmbeddedFiles(files);
|
||||||
return $fileObservers;
|
$fileOrder = ids;
|
||||||
|
selection.selectAll();
|
||||||
});
|
});
|
||||||
|
if (allowedEmbeddingBasemaps.includes(options.basemap)) {
|
||||||
$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;
|
$currentBasemap = options.basemap;
|
||||||
}
|
}
|
||||||
|
if (!isSelected($selectedBasemapTree, options.basemap)) {
|
||||||
if (options.distanceMarkers !== $distanceMarkers) {
|
$selectedBasemapTree = toggle($selectedBasemapTree, options.basemap);
|
||||||
|
}
|
||||||
$distanceMarkers = options.distanceMarkers;
|
$distanceMarkers = options.distanceMarkers;
|
||||||
}
|
|
||||||
|
|
||||||
if (options.directionMarkers !== $directionMarkers) {
|
|
||||||
$directionMarkers = options.directionMarkers;
|
$directionMarkers = options.directionMarkers;
|
||||||
}
|
|
||||||
|
|
||||||
if (options.distanceUnits !== $distanceUnits) {
|
|
||||||
$distanceUnits = options.distanceUnits;
|
$distanceUnits = options.distanceUnits;
|
||||||
}
|
|
||||||
|
|
||||||
if (options.velocityUnits !== $velocityUnits) {
|
|
||||||
$velocityUnits = options.velocityUnits;
|
$velocityUnits = options.velocityUnits;
|
||||||
}
|
|
||||||
|
|
||||||
if (options.temperatureUnits !== $temperatureUnits) {
|
|
||||||
$temperatureUnits = options.temperatureUnits;
|
$temperatureUnits = options.temperatureUnits;
|
||||||
}
|
if (options.theme != 'system') {
|
||||||
|
|
||||||
if (options.theme !== $mode) {
|
|
||||||
setMode(options.theme);
|
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(() => {
|
$effect(() => {
|
||||||
prevSettings.distanceMarkers = $distanceMarkers;
|
options;
|
||||||
prevSettings.directionMarkers = $directionMarkers;
|
untrack(applyOptions);
|
||||||
prevSettings.distanceUnits = $distanceUnits;
|
|
||||||
prevSettings.velocityUnits = $velocityUnits;
|
|
||||||
prevSettings.temperatureUnits = $temperatureUnits;
|
|
||||||
prevSettings.theme = $mode ?? 'system';
|
|
||||||
});
|
|
||||||
|
|
||||||
$: if (browser && options) {
|
|
||||||
applyOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($fileOrder) {
|
|
||||||
updateGPXData();
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if ($distanceMarkers !== prevSettings.distanceMarkers) {
|
|
||||||
$distanceMarkers = prevSettings.distanceMarkers;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($directionMarkers !== prevSettings.directionMarkers) {
|
|
||||||
$directionMarkers = prevSettings.directionMarkers;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($distanceUnits !== prevSettings.distanceUnits) {
|
|
||||||
$distanceUnits = prevSettings.distanceUnits;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($velocityUnits !== prevSettings.velocityUnits) {
|
|
||||||
$velocityUnits = prevSettings.velocityUnits;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($temperatureUnits !== prevSettings.temperatureUnits) {
|
|
||||||
$temperatureUnits = prevSettings.temperatureUnits;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($mode !== prevSettings.theme) {
|
|
||||||
setMode(prevSettings.theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
$selection.clear();
|
|
||||||
$fileObservers.clear();
|
|
||||||
$fileOrder = $fileOrder.filter((id) => !id.includes('embed'));
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
|
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
|
||||||
<div class="grow relative">
|
<div class="grow relative">
|
||||||
<Map
|
<Map
|
||||||
class="h-full {$fileObservers.size > 1 ? 'horizontal' : ''}"
|
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
|
||||||
accessToken={options.token}
|
accessToken={options.token}
|
||||||
geocoder={false}
|
geocoder={false}
|
||||||
geolocate={false}
|
geolocate={true}
|
||||||
hash={useHash}
|
hash={useHash}
|
||||||
/>
|
/>
|
||||||
<OpenIn bind:files={options.files} bind:ids={options.ids} />
|
<OpenIn files={options.files} ids={options.ids} />
|
||||||
<LayerControl />
|
<LayerControl />
|
||||||
<GPXLayers />
|
<GPXLayers />
|
||||||
{#if $fileObservers.size > 1}
|
{#if $fileStateCollection.size > 1}
|
||||||
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
|
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
|
||||||
<FileList orientation="horizontal" />
|
<FileList orientation="horizontal" />
|
||||||
</div>
|
</div>
|
||||||
@@ -252,14 +130,8 @@
|
|||||||
<ElevationProfile
|
<ElevationProfile
|
||||||
{gpxStatistics}
|
{gpxStatistics}
|
||||||
{slicedGPXStatistics}
|
{slicedGPXStatistics}
|
||||||
additionalDatasets={[
|
{additionalDatasets}
|
||||||
options.elevation.speed ? 'speed' : null,
|
{elevationFill}
|
||||||
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}
|
|
||||||
showControls={options.elevation.controls}
|
showControls={options.elevation.controls}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -13,68 +13,66 @@
|
|||||||
SquareActivity,
|
SquareActivity,
|
||||||
Coins,
|
Coins,
|
||||||
Milestone,
|
Milestone,
|
||||||
Video
|
Video,
|
||||||
} from 'lucide-svelte';
|
} from '@lucide/svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import {
|
import {
|
||||||
allowedEmbeddingBasemaps,
|
allowedEmbeddingBasemaps,
|
||||||
|
defaultEmbeddingOptions,
|
||||||
getCleanedEmbeddingOptions,
|
getCleanedEmbeddingOptions,
|
||||||
getDefaultEmbeddingOptions
|
getMergedEmbeddingOptions,
|
||||||
} from './Embedding';
|
} from './embedding';
|
||||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||||
import Embedding from './Embedding.svelte';
|
import Embedding from './Embedding.svelte';
|
||||||
import { map } from '$lib/stores';
|
import { onDestroy } from 'svelte';
|
||||||
import { tick } from 'svelte';
|
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
|
|
||||||
let options = getDefaultEmbeddingOptions();
|
let options = $state(
|
||||||
options.token = 'YOUR_MAPBOX_TOKEN';
|
getMergedEmbeddingOptions(
|
||||||
options.files = [
|
{
|
||||||
|
token: 'YOUR_MAPBOX_TOKEN',
|
||||||
|
theme: mode.current,
|
||||||
|
},
|
||||||
|
defaultEmbeddingOptions
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let files = $state(
|
||||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
|
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
|
||||||
];
|
);
|
||||||
|
let driveIds = $state('');
|
||||||
|
|
||||||
let files = options.files[0];
|
let iframeOptions = $derived(
|
||||||
$: {
|
getMergedEmbeddingOptions(
|
||||||
let urls = files.split(',');
|
{
|
||||||
urls = urls.filter((url) => url.length > 0);
|
token:
|
||||||
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
|
|
||||||
options.files = urls;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let driveIds = '';
|
|
||||||
$: {
|
|
||||||
let ids = driveIds.split(',');
|
|
||||||
ids = ids.filter((id) => id.length > 0);
|
|
||||||
if (JSON.stringify(ids) !== JSON.stringify(options.ids)) {
|
|
||||||
options.ids = ids;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let manualCamera = false;
|
|
||||||
|
|
||||||
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'
|
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
||||||
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
|
? PUBLIC_MAPBOX_TOKEN
|
||||||
: options;
|
: options.token,
|
||||||
|
files: files.split(',').filter((url) => url.length > 0),
|
||||||
|
ids: driveIds.split(',').filter((id) => id.length > 0),
|
||||||
|
elevation: {
|
||||||
|
fill: options.elevation.fill === 'none' ? undefined : options.elevation.fill,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
async function resizeMap() {
|
let manualCamera = $state(false);
|
||||||
if ($map) {
|
let zoom = $state('0');
|
||||||
await tick();
|
let lat = $state('0');
|
||||||
$map.resize();
|
let lon = $state('0');
|
||||||
}
|
let bearing = $state('0');
|
||||||
}
|
let pitch = $state('0');
|
||||||
|
let hash = $derived(manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '');
|
||||||
|
|
||||||
$: if (options.elevation.height || options.elevation.show) {
|
$effect(() => {
|
||||||
resizeMap();
|
if (options.elevation.show || options.elevation.height) {
|
||||||
|
map.resize();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function updateCamera() {
|
function updateCamera() {
|
||||||
if ($map) {
|
if ($map) {
|
||||||
@@ -87,114 +85,116 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($map) {
|
map.onLoad((map_) => {
|
||||||
$map.on('moveend', updateCamera);
|
map_.on('moveend', updateCamera);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if ($map) {
|
||||||
|
$map.off('moveend', updateCamera);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root id="embedding-playground">
|
<Card.Root id="embedding-playground">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>{$_('embedding.title')}</Card.Title>
|
<Card.Title>{i18n._('embedding.title')}</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<fieldset class="flex flex-col gap-3">
|
<fieldset class="flex flex-col gap-3">
|
||||||
<Label for="token">{$_('embedding.mapbox_token')}</Label>
|
<Label for="token">{i18n._('embedding.mapbox_token')}</Label>
|
||||||
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
||||||
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
|
<Label for="file_urls">{i18n._('embedding.file_urls')}</Label>
|
||||||
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
||||||
<Label for="drive_ids">{$_('embedding.drive_ids')}</Label>
|
<Label for="drive_ids">{i18n._('embedding.drive_ids')}</Label>
|
||||||
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
|
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
|
||||||
<Label for="basemap">{$_('embedding.basemap')}</Label>
|
<Label for="basemap">{i18n._('embedding.basemap')}</Label>
|
||||||
<Select.Root
|
<Select.Root type="single" bind:value={options.basemap}>
|
||||||
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
|
|
||||||
onSelectedChange={(selected) => {
|
|
||||||
if (selected?.value) {
|
|
||||||
options.basemap = selected?.value;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Select.Trigger id="basemap" class="w-full h-8">
|
<Select.Trigger id="basemap" class="w-full h-8">
|
||||||
<Select.Value />
|
{i18n._(`layers.label.${options.basemap}`)}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||||
{#each allowedEmbeddingBasemaps as basemap}
|
{#each allowedEmbeddingBasemaps as basemap}
|
||||||
<Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
|
<Select.Item value={basemap}
|
||||||
|
>{i18n._(`layers.label.${basemap}`)}</Select.Item
|
||||||
|
>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Label for="profile">{$_('menu.elevation_profile')}</Label>
|
<Label for="profile">{i18n._('menu.elevation_profile')}</Label>
|
||||||
<Checkbox id="profile" bind:checked={options.elevation.show} />
|
<Checkbox id="profile" bind:checked={options.elevation.show} />
|
||||||
</div>
|
</div>
|
||||||
{#if options.elevation.show}
|
{#if options.elevation.show}
|
||||||
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
|
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
|
||||||
<Label class="flex flex-row items-center gap-2">
|
<Label class="flex flex-row items-center gap-2">
|
||||||
{$_('embedding.height')}
|
{i18n._('embedding.height')}
|
||||||
<Input type="number" bind:value={options.elevation.height} class="h-8 w-20" />
|
<Input
|
||||||
|
type="number"
|
||||||
|
bind:value={options.elevation.height}
|
||||||
|
class="h-8 w-20"
|
||||||
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<span class="shrink-0">
|
<span class="shrink-0">
|
||||||
{$_('embedding.fill_by')}
|
{i18n._('embedding.fill_by')}
|
||||||
</span>
|
</span>
|
||||||
<Select.Root
|
<Select.Root type="single" bind:value={options.elevation.fill}>
|
||||||
selected={{ value: 'none', label: $_('embedding.none') }}
|
|
||||||
onSelectedChange={(selected) => {
|
|
||||||
let value = selected?.value;
|
|
||||||
if (value === 'none') {
|
|
||||||
options.elevation.fill = undefined;
|
|
||||||
} else if (value === 'slope' || value === 'surface' || value === 'highway') {
|
|
||||||
options.elevation.fill = value;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Select.Trigger class="grow h-8">
|
<Select.Trigger class="grow h-8">
|
||||||
<Select.Value />
|
{options.elevation.fill !== 'none'
|
||||||
|
? i18n._(`quantities.${options.elevation.fill}`)
|
||||||
|
: i18n._('embedding.none')}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
|
<Select.Item value="slope">{i18n._('quantities.slope')}</Select.Item
|
||||||
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item>
|
>
|
||||||
<Select.Item value="highway">{$_('quantities.highway')}</Select.Item>
|
<Select.Item value="surface"
|
||||||
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
|
>{i18n._('quantities.surface')}</Select.Item
|
||||||
|
>
|
||||||
|
<Select.Item value="highway"
|
||||||
|
>{i18n._('quantities.highway')}</Select.Item
|
||||||
|
>
|
||||||
|
<Select.Item value="none">{i18n._('embedding.none')}</Select.Item>
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="controls" bind:checked={options.elevation.controls} />
|
<Checkbox id="controls" bind:checked={options.elevation.controls} />
|
||||||
<Label for="controls">{$_('embedding.show_controls')}</Label>
|
<Label for="controls">{i18n._('embedding.show_controls')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
|
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
|
||||||
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
||||||
<Zap size="16" />
|
<Zap size="16" />
|
||||||
{$_('quantities.speed')}
|
{i18n._('quantities.speed')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
|
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
|
||||||
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
||||||
<HeartPulse size="16" />
|
<HeartPulse size="16" />
|
||||||
{$_('quantities.heartrate')}
|
{i18n._('quantities.heartrate')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
|
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
|
||||||
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
||||||
<Orbit size="16" />
|
<Orbit size="16" />
|
||||||
{$_('quantities.cadence')}
|
{i18n._('quantities.cadence')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
|
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
|
||||||
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
||||||
<Thermometer size="16" />
|
<Thermometer size="16" />
|
||||||
{$_('quantities.temperature')}
|
{i18n._('quantities.temperature')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-power" bind:checked={options.elevation.power} />
|
<Checkbox id="show-power" bind:checked={options.elevation.power} />
|
||||||
<Label for="show-power" class="flex flex-row items-center gap-1">
|
<Label for="show-power" class="flex flex-row items-center gap-1">
|
||||||
<SquareActivity size="16" />
|
<SquareActivity size="16" />
|
||||||
{$_('quantities.power')}
|
{i18n._('quantities.power')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,75 +203,75 @@
|
|||||||
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
|
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
|
||||||
<Label for="distance-markers" class="flex flex-row items-center gap-1">
|
<Label for="distance-markers" class="flex flex-row items-center gap-1">
|
||||||
<Coins size="16" />
|
<Coins size="16" />
|
||||||
{$_('menu.distance_markers')}
|
{i18n._('menu.distance_markers')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
|
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
|
||||||
<Label for="direction-markers" class="flex flex-row items-center gap-1">
|
<Label for="direction-markers" class="flex flex-row items-center gap-1">
|
||||||
<Milestone size="16" />
|
<Milestone size="16" />
|
||||||
{$_('menu.direction_markers')}
|
{i18n._('menu.direction_markers')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row flex-wrap justify-between gap-3">
|
<div class="flex flex-row flex-wrap justify-between gap-3">
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<Label class="flex flex-col items-start gap-2">
|
||||||
{$_('menu.distance_units')}
|
{i18n._('menu.distance_units')}
|
||||||
<RadioGroup.Root bind:value={options.distanceUnits}>
|
<RadioGroup.Root bind:value={options.distanceUnits}>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="metric" id="metric" />
|
<RadioGroup.Item value="metric" id="metric" />
|
||||||
<Label for="metric">{$_('menu.metric')}</Label>
|
<Label for="metric">{i18n._('menu.metric')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="imperial" id="imperial" />
|
<RadioGroup.Item value="imperial" id="imperial" />
|
||||||
<Label for="imperial">{$_('menu.imperial')}</Label>
|
<Label for="imperial">{i18n._('menu.imperial')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="nautical" id="nautical" />
|
<RadioGroup.Item value="nautical" id="nautical" />
|
||||||
<Label for="nautical">{$_('menu.nautical')}</Label>
|
<Label for="nautical">{i18n._('menu.nautical')}</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<Label class="flex flex-col items-start gap-2">
|
||||||
{$_('menu.velocity_units')}
|
{i18n._('menu.velocity_units')}
|
||||||
<RadioGroup.Root bind:value={options.velocityUnits}>
|
<RadioGroup.Root bind:value={options.velocityUnits}>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="speed" id="speed" />
|
<RadioGroup.Item value="speed" id="speed" />
|
||||||
<Label for="speed">{$_('quantities.speed')}</Label>
|
<Label for="speed">{i18n._('quantities.speed')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="pace" id="pace" />
|
<RadioGroup.Item value="pace" id="pace" />
|
||||||
<Label for="pace">{$_('quantities.pace')}</Label>
|
<Label for="pace">{i18n._('quantities.pace')}</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<Label class="flex flex-col items-start gap-2">
|
||||||
{$_('menu.temperature_units')}
|
{i18n._('menu.temperature_units')}
|
||||||
<RadioGroup.Root bind:value={options.temperatureUnits}>
|
<RadioGroup.Root bind:value={options.temperatureUnits}>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="celsius" id="celsius" />
|
<RadioGroup.Item value="celsius" id="celsius" />
|
||||||
<Label for="celsius">{$_('menu.celsius')}</Label>
|
<Label for="celsius">{i18n._('menu.celsius')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
|
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
|
||||||
<Label for="fahrenheit">{$_('menu.fahrenheit')}</Label>
|
<Label for="fahrenheit">{i18n._('menu.fahrenheit')}</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<Label class="flex flex-col items-start gap-2">
|
||||||
{$_('menu.mode')}
|
{i18n._('menu.mode')}
|
||||||
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
|
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="system" id="system" />
|
<RadioGroup.Item value="system" id="system" />
|
||||||
<Label for="system">{$_('menu.system')}</Label>
|
<Label for="system">{i18n._('menu.system')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="light" id="light" />
|
<RadioGroup.Item value="light" id="light" />
|
||||||
<Label for="light">{$_('menu.light')}</Label>
|
<Label for="light">{i18n._('menu.light')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="dark" id="dark" />
|
<RadioGroup.Item value="dark" id="dark" />
|
||||||
<Label for="dark">{$_('menu.dark')}</Label>
|
<Label for="dark">{i18n._('menu.dark')}</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -280,47 +280,48 @@
|
|||||||
<Checkbox id="manual-camera" bind:checked={manualCamera} />
|
<Checkbox id="manual-camera" bind:checked={manualCamera} />
|
||||||
<Label for="manual-camera" class="flex flex-row items-center gap-1">
|
<Label for="manual-camera" class="flex flex-row items-center gap-1">
|
||||||
<Video size="16" />
|
<Video size="16" />
|
||||||
{$_('embedding.manual_camera')}
|
{i18n._('embedding.manual_camera')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
{$_('embedding.manual_camera_description')}
|
{i18n._('embedding.manual_camera_description')}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-row flex-wrap items-center gap-6">
|
<div class="flex flex-row flex-wrap items-center gap-6">
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.latitude')}</span>
|
<span>{i18n._('embedding.latitude')}</span>
|
||||||
<span>{lat}</span>
|
<span>{lat}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.longitude')}</span>
|
<span>{i18n._('embedding.longitude')}</span>
|
||||||
<span>{lon}</span>
|
<span>{lon}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.zoom')}</span>
|
<span>{i18n._('embedding.zoom')}</span>
|
||||||
<span>{zoom}</span>
|
<span>{zoom}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.bearing')}</span>
|
<span>{i18n._('embedding.bearing')}</span>
|
||||||
<span>{bearing}</span>
|
<span>{bearing}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.pitch')}</span>
|
<span>{i18n._('embedding.pitch')}</span>
|
||||||
<span>{pitch}</span>
|
<span>{pitch}</span>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Label>
|
<Label>
|
||||||
{$_('embedding.preview')}
|
{i18n._('embedding.preview')}
|
||||||
</Label>
|
</Label>
|
||||||
<div class="relative h-[600px]">
|
<div class="relative h-[600px]">
|
||||||
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
|
<Embedding options={iframeOptions} bind:hash useHash={false} />
|
||||||
</div>
|
</div>
|
||||||
<Label>
|
<Label>
|
||||||
{$_('embedding.code')}
|
{i18n._('embedding.code')}
|
||||||
</Label>
|
</Label>
|
||||||
<pre class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
<pre
|
||||||
|
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
||||||
<code class="language-html">
|
<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>
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -2,22 +2,27 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import Logo from '$lib/components/Logo.svelte';
|
import Logo from '$lib/components/Logo.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { _, locale } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
export let files: string[];
|
let {
|
||||||
export let ids: string[];
|
files,
|
||||||
|
ids,
|
||||||
|
}: {
|
||||||
|
files: string[];
|
||||||
|
ids: string[];
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="absolute top-0 flex-wrap h-fit bg-background font-semibold rounded-md py-1 px-2 gap-1.5 xs:text-base mt-2.5 ml-2.5 mr-12"
|
class="absolute top-0 flex-wrap h-fit bg-background font-semibold rounded-md py-1 px-2 gap-1.5 xs:text-base mt-2.5 ml-2.5 mr-12"
|
||||||
href="{getURLForLanguage($locale, '/app')}?{files.length > 0
|
href="{getURLForLanguage(i18n.lang, '/app')}?{files.length > 0
|
||||||
? `files=${encodeURIComponent(JSON.stringify(files))}`
|
? `files=${encodeURIComponent(JSON.stringify(files))}`
|
||||||
: ''}{files.length > 0 && ids.length > 0 ? '&' : ''}{ids.length > 0
|
: ''}{files.length > 0 && ids.length > 0 ? '&' : ''}{ids.length > 0
|
||||||
? `ids=${encodeURIComponent(JSON.stringify(ids))}`
|
? `ids=${encodeURIComponent(JSON.stringify(ids))}`
|
||||||
: ''}"
|
: ''}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{$_('menu.open_in')}
|
{i18n._('menu.open_in')}
|
||||||
<Logo class="h-[18px] xs:h-5 translate-y-[1px]" />
|
<Logo class="h-[18px] xs:h-5 translate-y-[1px]" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type EmbeddingOptions = {
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
height: number;
|
height: number;
|
||||||
controls: boolean;
|
controls: boolean;
|
||||||
fill: 'slope' | 'surface' | 'highway' | undefined;
|
fill: 'slope' | 'surface' | 'highway' | 'none';
|
||||||
speed: boolean;
|
speed: boolean;
|
||||||
hr: boolean;
|
hr: boolean;
|
||||||
cad: boolean;
|
cad: boolean;
|
||||||
@@ -34,32 +34,32 @@ export const defaultEmbeddingOptions = {
|
|||||||
show: true,
|
show: true,
|
||||||
height: 170,
|
height: 170,
|
||||||
controls: true,
|
controls: true,
|
||||||
fill: undefined,
|
fill: 'none',
|
||||||
speed: false,
|
speed: false,
|
||||||
hr: false,
|
hr: false,
|
||||||
cad: false,
|
cad: false,
|
||||||
temp: false,
|
temp: false,
|
||||||
power: false
|
power: false,
|
||||||
},
|
},
|
||||||
distanceMarkers: false,
|
distanceMarkers: false,
|
||||||
directionMarkers: false,
|
directionMarkers: false,
|
||||||
distanceUnits: 'metric',
|
distanceUnits: 'metric',
|
||||||
velocityUnits: 'speed',
|
velocityUnits: 'speed',
|
||||||
temperatureUnits: 'celsius',
|
temperatureUnits: 'celsius',
|
||||||
theme: 'system'
|
theme: 'system',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
|
|
||||||
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMergedEmbeddingOptions(
|
export function getMergedEmbeddingOptions(
|
||||||
options: any,
|
options: any,
|
||||||
defaultOptions: any = defaultEmbeddingOptions
|
defaultOptions: any = defaultEmbeddingOptions
|
||||||
): EmbeddingOptions {
|
): EmbeddingOptions {
|
||||||
const mergedOptions = JSON.parse(JSON.stringify(defaultOptions));
|
const mergedOptions = JSON.parse(JSON.stringify(defaultOptions));
|
||||||
for (const key in options) {
|
for (const key in options) {
|
||||||
if (typeof options[key] === 'object' && options[key] !== null && !Array.isArray(options[key])) {
|
if (
|
||||||
|
typeof options[key] === 'object' &&
|
||||||
|
options[key] !== null &&
|
||||||
|
!Array.isArray(options[key])
|
||||||
|
) {
|
||||||
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
|
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
|
||||||
} else {
|
} else {
|
||||||
mergedOptions[key] = options[key];
|
mergedOptions[key] = options[key];
|
||||||
@@ -79,7 +79,10 @@ export function getCleanedEmbeddingOptions(
|
|||||||
cleanedOptions[key] !== null &&
|
cleanedOptions[key] !== null &&
|
||||||
!Array.isArray(cleanedOptions[key])
|
!Array.isArray(cleanedOptions[key])
|
||||||
) {
|
) {
|
||||||
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
|
cleanedOptions[key] = getCleanedEmbeddingOptions(
|
||||||
|
cleanedOptions[key],
|
||||||
|
defaultOptions[key]
|
||||||
|
);
|
||||||
if (Object.keys(cleanedOptions[key]).length === 0) {
|
if (Object.keys(cleanedOptions[key]).length === 0) {
|
||||||
delete cleanedOptions[key];
|
delete cleanedOptions[key];
|
||||||
}
|
}
|
||||||
@@ -141,7 +144,7 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
|
|||||||
}
|
}
|
||||||
if (options.has('slope')) {
|
if (options.has('slope')) {
|
||||||
newOptions.elevation = {
|
newOptions.elevation = {
|
||||||
fill: 'slope'
|
fill: 'slope',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return newOptions;
|
return newOptions;
|
||||||
195
website/src/lib/components/export/Export.svelte
Normal file
195
website/src/lib/components/export/Export.svelte
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
import { Dialog } from 'bits-ui';
|
||||||
|
import {
|
||||||
|
exportAllFiles,
|
||||||
|
exportSelectedFiles,
|
||||||
|
ExportState,
|
||||||
|
exportState,
|
||||||
|
} from '$lib/components/export/utils.svelte';
|
||||||
|
import { currentTool } from '$lib/components/toolbar/tools';
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Zap,
|
||||||
|
Earth,
|
||||||
|
HeartPulse,
|
||||||
|
Orbit,
|
||||||
|
Thermometer,
|
||||||
|
SquareActivity,
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { GPXStatistics } from 'gpx';
|
||||||
|
import { ListRootItem } from '$lib/components/file-list/file-list';
|
||||||
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import { selection } from '$lib/logic/selection';
|
||||||
|
import { gpxStatistics } from '$lib/logic/statistics';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
let open = $derived(exportState.current !== ExportState.NONE);
|
||||||
|
let exportOptions: Record<string, boolean> = $state({
|
||||||
|
time: true,
|
||||||
|
hr: true,
|
||||||
|
cad: true,
|
||||||
|
atemp: true,
|
||||||
|
power: true,
|
||||||
|
extensions: false,
|
||||||
|
});
|
||||||
|
let hide: Record<string, boolean> = $derived.by(() => {
|
||||||
|
if (exportState.current === ExportState.NONE) {
|
||||||
|
return {
|
||||||
|
time: false,
|
||||||
|
hr: false,
|
||||||
|
cad: false,
|
||||||
|
atemp: false,
|
||||||
|
power: false,
|
||||||
|
extensions: false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
let statistics = $gpxStatistics;
|
||||||
|
if (exportState.current === ExportState.ALL) {
|
||||||
|
statistics = Array.from(get(fileStateCollection).values())
|
||||||
|
.map((file) => file.statistics)
|
||||||
|
.reduce((acc, cur) => {
|
||||||
|
if (cur !== undefined) {
|
||||||
|
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, new GPXStatistics());
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
time: statistics.global.time.total === 0,
|
||||||
|
hr: statistics.global.hr.count === 0,
|
||||||
|
cad: statistics.global.cad.count === 0,
|
||||||
|
atemp: statistics.global.atemp.count === 0,
|
||||||
|
power: statistics.global.power.count === 0,
|
||||||
|
extensions: Object.keys(statistics.global.extensions).length === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let exclude = $derived(Object.keys(exportOptions).filter((key) => !exportOptions[key]));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
currentTool.set(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root
|
||||||
|
bind:open
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
exportState.current = ExportState.NONE;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog.Trigger class="hidden" />
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Content
|
||||||
|
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-col sm:flex-row items-center justify-center gap-1 sm:gap-2 border rounded-md p-2 bg-secondary"
|
||||||
|
>
|
||||||
|
<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>🙏</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="grow"
|
||||||
|
onclick={() => {
|
||||||
|
if (exportState.current === ExportState.SELECTION) {
|
||||||
|
exportSelectedFiles(exclude);
|
||||||
|
} else if (exportState.current === ExportState.ALL) {
|
||||||
|
exportAllFiles(exclude);
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
exportState.current = ExportState.NONE;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download size="16" />
|
||||||
|
{#if $fileStateCollection.size === 1 || (exportState.current === ExportState.SELECTION && $selection.size === 1)}
|
||||||
|
{i18n._('menu.download_file')}
|
||||||
|
{:else}
|
||||||
|
{i18n._('menu.download_files')}
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-full max-w-xl flex flex-col items-center gap-2 {Object.values(hide).some(
|
||||||
|
(v) => !v
|
||||||
|
)
|
||||||
|
? ''
|
||||||
|
: 'hidden'}"
|
||||||
|
>
|
||||||
|
<div class="w-full flex flex-row items-center gap-3">
|
||||||
|
<div class="grow">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
<Label class="shrink-0">
|
||||||
|
{i18n._('menu.export_options')}
|
||||||
|
</Label>
|
||||||
|
<div class="grow">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row flex-wrap justify-center gap-x-6 gap-y-2">
|
||||||
|
<div class="flex flex-row items-center gap-1.5 {hide.time ? 'hidden' : ''}">
|
||||||
|
<Checkbox id="export-time" bind:checked={exportOptions.time} />
|
||||||
|
<Label for="export-time" class="flex flex-row items-center gap-1">
|
||||||
|
<Zap size="16" />
|
||||||
|
{i18n._('quantities.time')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
|
||||||
|
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
|
||||||
|
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
|
||||||
|
<HeartPulse size="16" />
|
||||||
|
{i18n._('quantities.heartrate')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
|
||||||
|
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
|
||||||
|
<Label for="export-cadence" class="flex flex-row items-center gap-1">
|
||||||
|
<Orbit size="16" />
|
||||||
|
{i18n._('quantities.cadence')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
|
||||||
|
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
|
||||||
|
<Label for="export-temperature" class="flex flex-row items-center gap-1">
|
||||||
|
<Thermometer size="16" />
|
||||||
|
{i18n._('quantities.temperature')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
|
||||||
|
<Checkbox id="export-power" bind:checked={exportOptions.power} />
|
||||||
|
<Label for="export-power" class="flex flex-row items-center gap-1">
|
||||||
|
<SquareActivity size="16" />
|
||||||
|
{i18n._('quantities.power')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-row items-center gap-1.5 {hide.extensions ? 'hidden' : ''}"
|
||||||
|
>
|
||||||
|
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
|
||||||
|
<Label for="export-extensions" class="flex flex-row items-center gap-1">
|
||||||
|
<Earth size="16" />
|
||||||
|
{i18n._('quantities.osm_extensions')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
66
website/src/lib/components/export/utils.svelte.ts
Normal file
66
website/src/lib/components/export/utils.svelte.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { selection } from '$lib/logic/selection';
|
||||||
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { buildGPX, type GPXFile } from 'gpx';
|
||||||
|
import FileSaver from 'file-saver';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
export enum ExportState {
|
||||||
|
NONE,
|
||||||
|
SELECTION,
|
||||||
|
ALL,
|
||||||
|
}
|
||||||
|
export const exportState = $state({
|
||||||
|
current: ExportState.NONE,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function exportFiles(fileIds: string[], exclude: string[]) {
|
||||||
|
if (fileIds.length > 1) {
|
||||||
|
await exportFilesAsZip(fileIds, exclude);
|
||||||
|
} else {
|
||||||
|
const firstFileId = fileIds.at(0);
|
||||||
|
if (firstFileId != null) {
|
||||||
|
const file = fileStateCollection.getFile(firstFileId);
|
||||||
|
if (file) {
|
||||||
|
exportFile(file, exclude);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportSelectedFiles(exclude: string[]) {
|
||||||
|
const fileIds: string[] = [];
|
||||||
|
selection.applyToOrderedSelectedItemsFromFile(async (fileId, level, items) => {
|
||||||
|
fileIds.push(fileId);
|
||||||
|
});
|
||||||
|
await exportFiles(fileIds, exclude);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportAllFiles(exclude: string[]) {
|
||||||
|
await exportFiles(get(settings.fileOrder), exclude);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportFile(file: GPXFile, exclude: string[]) {
|
||||||
|
const blob = new Blob([buildGPX(file, exclude)], { type: 'application/gpx+xml' });
|
||||||
|
FileSaver.saveAs(blob, `${file.metadata.name}.gpx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportFilesAsZip(fileIds: string[], exclude: string[]) {
|
||||||
|
const zip = new JSZip();
|
||||||
|
for (const fileId of fileIds) {
|
||||||
|
const file = fileStateCollection.getFile(fileId);
|
||||||
|
if (file) {
|
||||||
|
const gpx = buildGPX(file, exclude);
|
||||||
|
let filename = file.metadata.name;
|
||||||
|
for (let i = 1; zip.files[filename + '.gpx']; i++) {
|
||||||
|
filename = file.metadata.name + `-${i}`;
|
||||||
|
}
|
||||||
|
zip.file(filename + '.gpx', gpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(zip.files).length > 0) {
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
FileSaver.saveAs(blob, 'gpx-files.zip');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,34 +2,33 @@
|
|||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
|
||||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||||
import FileListNode from './FileListNode.svelte';
|
import FileListNode from './FileListNode.svelte';
|
||||||
import { fileObservers, settings } from '$lib/db';
|
import { onMount, setContext } from 'svelte';
|
||||||
import { setContext } from 'svelte';
|
import { ListFileItem, ListLevel, ListRootItem } from './file-list';
|
||||||
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './FileList';
|
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
|
||||||
import { copied, pasteSelection, selectAll, selection } from './Selection';
|
|
||||||
import { ClipboardPaste, FileStack, Plus } from 'lucide-svelte';
|
|
||||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { createFile } from '$lib/stores';
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import { createFile, pasteSelection } from '$lib/logic/file-actions';
|
||||||
|
import { selection, copied } from '$lib/logic/selection';
|
||||||
|
import { allowedPastes } from './sortable-file-list';
|
||||||
|
|
||||||
export let orientation: 'vertical' | 'horizontal';
|
let {
|
||||||
export let recursive = false;
|
orientation,
|
||||||
|
recursive = false,
|
||||||
|
class: className = '',
|
||||||
|
style = '',
|
||||||
|
}: {
|
||||||
|
orientation: 'vertical' | 'horizontal';
|
||||||
|
recursive?: boolean;
|
||||||
|
class?: string;
|
||||||
|
style?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
setContext('orientation', orientation);
|
setContext('orientation', orientation);
|
||||||
setContext('recursive', recursive);
|
setContext('recursive', recursive);
|
||||||
|
|
||||||
const { verticalFileView } = settings;
|
onMount(() => {
|
||||||
|
if (orientation === 'horizontal') {
|
||||||
verticalFileView.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.update(($selection) => {
|
||||||
$selection.forEach((item) => {
|
$selection.forEach((item) => {
|
||||||
if (!(item instanceof ListFileItem)) {
|
if (!(item instanceof ListFileItem)) {
|
||||||
@@ -46,29 +45,32 @@
|
|||||||
<ScrollArea
|
<ScrollArea
|
||||||
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
|
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
|
||||||
{orientation}
|
{orientation}
|
||||||
scrollbarXClasses={orientation === 'vertical' ? '' : 'mt-1 h-2'}
|
scrollbarXClasses={orientation === 'vertical' ? '' : 'hidden'}
|
||||||
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
|
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex {orientation === 'vertical'
|
class="flex {orientation === 'vertical'
|
||||||
? 'flex-col py-1 pl-1 min-h-screen'
|
? 'flex-col py-1 pl-1 min-h-screen'
|
||||||
: 'flex-row'} {$$props.class ?? ''}"
|
: 'flex-row'} {className ?? ''}"
|
||||||
{...$$restProps}
|
{style}
|
||||||
>
|
>
|
||||||
<FileListNode bind:node={$fileObservers} item={new ListRootItem()} />
|
<FileListNode node={$fileStateCollection} item={new ListRootItem()} />
|
||||||
{#if orientation === 'vertical'}
|
{#if orientation === 'vertical'}
|
||||||
<ContextMenu.Root>
|
<ContextMenu.Root>
|
||||||
<ContextMenu.Trigger class="grow" />
|
<ContextMenu.Trigger class="grow" />
|
||||||
<ContextMenu.Content>
|
<ContextMenu.Content>
|
||||||
<ContextMenu.Item on:click={createFile}>
|
<ContextMenu.Item onclick={createFile}>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" />
|
||||||
{$_('menu.new_file')}
|
{i18n._('menu.new_file')}
|
||||||
<Shortcut key="+" ctrl={true} />
|
<Shortcut key="+" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
<ContextMenu.Item on:click={selectAll} disabled={$fileObservers.size === 0}>
|
<ContextMenu.Item
|
||||||
<FileStack size="16" class="mr-1" />
|
onclick={() => selection.selectAll()}
|
||||||
{$_('menu.select_all')}
|
disabled={$fileStateCollection.size === 0}
|
||||||
|
>
|
||||||
|
<FileStack size="16" />
|
||||||
|
{i18n._('menu.select_all')}
|
||||||
<Shortcut key="A" ctrl={true} />
|
<Shortcut key="A" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
@@ -76,10 +78,10 @@
|
|||||||
disabled={$copied === undefined ||
|
disabled={$copied === undefined ||
|
||||||
$copied.length === 0 ||
|
$copied.length === 0 ||
|
||||||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
|
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
|
||||||
on:click={pasteSelection}
|
onclick={pasteSelection}
|
||||||
>
|
>
|
||||||
<ClipboardPaste size="16" class="mr-1" />
|
<ClipboardPaste size="16" />
|
||||||
{$_('menu.paste')}
|
{i18n._('menu.paste')}
|
||||||
<Shortcut key="V" ctrl={true} />
|
<Shortcut key="V" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
|
|||||||
@@ -5,62 +5,64 @@
|
|||||||
TrackSegment,
|
TrackSegment,
|
||||||
Waypoint,
|
Waypoint,
|
||||||
type AnyGPXTreeElement,
|
type AnyGPXTreeElement,
|
||||||
type GPXTreeElement
|
type GPXTreeElement,
|
||||||
} from 'gpx';
|
} from 'gpx';
|
||||||
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
||||||
import { settings, type GPXFileWithStatistics } from '$lib/db';
|
import { type Readable } from 'svelte/store';
|
||||||
import { get, type Readable } from 'svelte/store';
|
|
||||||
import FileListNodeContent from './FileListNodeContent.svelte';
|
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||||
import FileListNodeLabel from './FileListNodeLabel.svelte';
|
import FileListNodeLabel from './FileListNodeLabel.svelte';
|
||||||
import { afterUpdate, getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import {
|
import {
|
||||||
ListFileItem,
|
ListFileItem,
|
||||||
ListTrackSegmentItem,
|
ListTrackSegmentItem,
|
||||||
ListWaypointItem,
|
ListWaypointItem,
|
||||||
ListWaypointsItem,
|
ListWaypointsItem,
|
||||||
type ListItem,
|
type ListItem,
|
||||||
type ListTrackItem
|
type ListTrackItem,
|
||||||
} from './FileList';
|
} from './file-list';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { selection } from './Selection';
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||||
|
import { selection } from '$lib/logic/selection';
|
||||||
|
|
||||||
export let node:
|
let {
|
||||||
|
node,
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
node:
|
||||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||||
| GPXTreeElement<AnyGPXTreeElement>
|
| GPXTreeElement<AnyGPXTreeElement>
|
||||||
| Waypoint[]
|
| Waypoint[]
|
||||||
| Waypoint;
|
| Waypoint;
|
||||||
export let item: ListItem;
|
item: ListItem;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let recursive = getContext<boolean>('recursive');
|
let recursive = getContext<boolean>('recursive');
|
||||||
|
|
||||||
let collapsible: CollapsibleTreeNode;
|
let collapsible: CollapsibleTreeNode | undefined = $state();
|
||||||
|
|
||||||
$: label =
|
let label = $derived(
|
||||||
node instanceof GPXFile && item instanceof ListFileItem
|
node instanceof GPXFile && item instanceof ListFileItem
|
||||||
? node.metadata.name
|
? node.metadata.name
|
||||||
: node instanceof Track
|
: node instanceof Track
|
||||||
? node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`
|
? (node.name ?? `${i18n._('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
|
||||||
: node instanceof TrackSegment
|
: node instanceof TrackSegment
|
||||||
? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
|
? `${i18n._('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
|
||||||
: node instanceof Waypoint
|
: node instanceof Waypoint
|
||||||
? node.name ?? `${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`
|
? (node.name ??
|
||||||
|
`${i18n._('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
|
||||||
: node instanceof GPXFile && item instanceof ListWaypointsItem
|
: node instanceof GPXFile && item instanceof ListWaypointsItem
|
||||||
? $_('gpx.waypoints')
|
? i18n._('gpx.waypoints')
|
||||||
: '';
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
const { verticalFileView } = settings;
|
const { treeFileView } = settings;
|
||||||
|
|
||||||
function openIfSelectedChild() {
|
$effect(() => {
|
||||||
if (collapsible && get(verticalFileView) && $selection.hasAnyChildren(item, false)) {
|
if (collapsible && $treeFileView && $selection.hasAnyChildren(item, false)) {
|
||||||
collapsible.openNode();
|
collapsible.openNode();
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
if ($selection) {
|
|
||||||
openIfSelectedChild();
|
|
||||||
}
|
|
||||||
|
|
||||||
afterUpdate(openIfSelectedChild);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if node instanceof Map}
|
{#if node instanceof Map}
|
||||||
@@ -71,12 +73,16 @@
|
|||||||
<FileListNodeLabel {node} {item} {label} />
|
<FileListNodeLabel {node} {item} {label} />
|
||||||
{:else if recursive}
|
{:else if recursive}
|
||||||
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
|
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
|
||||||
<FileListNodeLabel {node} {item} {label} slot="trigger" />
|
{#snippet trigger()}
|
||||||
<div slot="content" class="ml-2">
|
<FileListNodeLabel {node} {item} {label} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet content()}
|
||||||
|
<div class="ml-4">
|
||||||
{#key node}
|
{#key node}
|
||||||
<FileListNodeContent {node} {item} />
|
<FileListNodeContent {node} {item} />
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
|
{/snippet}
|
||||||
</CollapsibleTreeNode>
|
</CollapsibleTreeNode>
|
||||||
{:else}
|
{:else}
|
||||||
<FileListNodeLabel {node} {item} {label} />
|
<FileListNodeLabel {node} {item} {label} />
|
||||||
|
|||||||
@@ -1,39 +1,29 @@
|
|||||||
<script lang="ts" context="module">
|
|
||||||
let dragging: Writable<ListLevel | null> = writable(null);
|
|
||||||
|
|
||||||
let updating = false;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
|
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
|
||||||
import { afterUpdate, getContext, onDestroy, onMount } from 'svelte';
|
import { getContext, onDestroy, onMount } from 'svelte';
|
||||||
import Sortable from 'sortablejs/Sortable';
|
import { type Readable } from 'svelte/store';
|
||||||
import { getFileIds, settings, type GPXFileWithStatistics } from '$lib/db';
|
|
||||||
import { get, writable, type Readable, type Writable } from 'svelte/store';
|
|
||||||
import FileListNodeStore from './FileListNodeStore.svelte';
|
import FileListNodeStore from './FileListNodeStore.svelte';
|
||||||
import FileListNode from './FileListNode.svelte';
|
import FileListNode from './FileListNode.svelte';
|
||||||
import {
|
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||||
ListFileItem,
|
import { ListFileItem, ListLevel, ListWaypointsItem, type ListItem } from './file-list';
|
||||||
ListLevel,
|
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||||
ListRootItem,
|
import { allowedMoves, dragging, SortableFileList } from './sortable-file-list';
|
||||||
ListWaypointsItem,
|
|
||||||
allowedMoves,
|
|
||||||
moveItems,
|
|
||||||
type ListItem
|
|
||||||
} from './FileList';
|
|
||||||
import { selection } from './Selection';
|
|
||||||
import { isMac } from '$lib/utils';
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
|
|
||||||
export let node:
|
let {
|
||||||
|
node,
|
||||||
|
item,
|
||||||
|
waypointRoot = false,
|
||||||
|
}: {
|
||||||
|
node:
|
||||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||||
| GPXTreeElement<AnyGPXTreeElement>
|
| GPXTreeElement<AnyGPXTreeElement>
|
||||||
|
| Waypoint[]
|
||||||
| Waypoint;
|
| Waypoint;
|
||||||
export let item: ListItem;
|
item: ListItem;
|
||||||
export let waypointRoot: boolean = false;
|
waypointRoot?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let container: HTMLElement;
|
let container: HTMLElement;
|
||||||
let elements: { [id: string]: HTMLElement } = {};
|
|
||||||
let sortableLevel: ListLevel =
|
let sortableLevel: ListLevel =
|
||||||
node instanceof Map
|
node instanceof Map
|
||||||
? ListLevel.FILE
|
? ListLevel.FILE
|
||||||
@@ -46,255 +36,32 @@
|
|||||||
: node instanceof Track
|
: node instanceof Track
|
||||||
? ListLevel.SEGMENT
|
? ListLevel.SEGMENT
|
||||||
: ListLevel.WAYPOINT;
|
: ListLevel.WAYPOINT;
|
||||||
let sortable: Sortable;
|
|
||||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||||
|
|
||||||
let destroyed = false;
|
let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel));
|
||||||
let lastUpdateStart = 0;
|
|
||||||
function updateToSelection(e) {
|
|
||||||
if (destroyed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastUpdateStart = Date.now();
|
let sortable: SortableFileList;
|
||||||
setTimeout(() => {
|
|
||||||
if (Date.now() - lastUpdateStart >= 40) {
|
|
||||||
if (updating) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updating = true;
|
onMount(() => {
|
||||||
// Sortable updates selection
|
sortable = new SortableFileList(
|
||||||
let changed = getChangedIds();
|
container,
|
||||||
if (changed.length > 0) {
|
node,
|
||||||
selection.update(($selection) => {
|
item,
|
||||||
$selection.clear();
|
waypointRoot,
|
||||||
Object.entries(elements).forEach(([id, element]) => {
|
sortableLevel,
|
||||||
$selection.set(
|
orientation
|
||||||
item.extend(getRealId(id)),
|
|
||||||
element.classList.contains('sortable-selected')
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
$effect(() => {
|
||||||
e.originalEvent &&
|
if (sortable && node) {
|
||||||
!(
|
sortable.updateElements();
|
||||||
e.originalEvent.ctrlKey ||
|
|
||||||
e.originalEvent.metaKey ||
|
|
||||||
e.originalEvent.shiftKey
|
|
||||||
) &&
|
|
||||||
($selection.size > 1 ||
|
|
||||||
!$selection.has(item.extend(getRealId(changed[0]))))
|
|
||||||
) {
|
|
||||||
// Fix bug that sometimes causes a single select to be treated as a multi-select
|
|
||||||
$selection.clear();
|
|
||||||
$selection.set(item.extend(getRealId(changed[0])), true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $selection;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
updating = false;
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFromSelection() {
|
|
||||||
if (destroyed || updating) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updating = true;
|
|
||||||
// Selection updates sortable
|
|
||||||
let changed = getChangedIds();
|
|
||||||
for (let id of changed) {
|
|
||||||
let element = elements[id];
|
|
||||||
if (element) {
|
|
||||||
if ($selection.has(item.extend(id))) {
|
|
||||||
Sortable.utils.select(element);
|
|
||||||
element.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'nearest'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Sortable.utils.deselect(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updating = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($selection) {
|
|
||||||
updateFromSelection();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { fileOrder } = settings;
|
|
||||||
function syncFileOrder() {
|
|
||||||
if (!sortable || sortableLevel !== ListLevel.FILE) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentOrder = sortable.toArray();
|
|
||||||
if (currentOrder.length !== $fileOrder.length) {
|
|
||||||
sortable.sort($fileOrder);
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < currentOrder.length; i++) {
|
|
||||||
if (currentOrder[i] !== $fileOrder[i]) {
|
|
||||||
sortable.sort($fileOrder);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($fileOrder) {
|
|
||||||
syncFileOrder();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSortable() {
|
|
||||||
sortable = Sortable.create(container, {
|
|
||||||
group: {
|
|
||||||
name: sortableLevel,
|
|
||||||
pull: allowedMoves[sortableLevel],
|
|
||||||
put: true
|
|
||||||
},
|
|
||||||
direction: orientation,
|
|
||||||
forceAutoScrollFallback: true,
|
|
||||||
multiDrag: true,
|
|
||||||
multiDragKey: isMac() ? 'Meta' : 'Ctrl',
|
|
||||||
avoidImplicitDeselect: true,
|
|
||||||
onSelect: updateToSelection,
|
|
||||||
onDeselect: updateToSelection,
|
|
||||||
onStart: () => {
|
|
||||||
dragging.set(sortableLevel);
|
|
||||||
},
|
|
||||||
onEnd: () => {
|
|
||||||
dragging.set(null);
|
|
||||||
},
|
|
||||||
onSort: (e) => {
|
|
||||||
if (sortableLevel === ListLevel.FILE) {
|
|
||||||
let newFileOrder = sortable.toArray();
|
|
||||||
if (newFileOrder.length !== get(fileOrder).length) {
|
|
||||||
fileOrder.set(newFileOrder);
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < newFileOrder.length; i++) {
|
|
||||||
if (newFileOrder[i] !== get(fileOrder)[i]) {
|
|
||||||
fileOrder.set(newFileOrder);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let fromItem = Sortable.get(e.from)._item;
|
|
||||||
let toItem = Sortable.get(e.to)._item;
|
|
||||||
|
|
||||||
if (item === toItem && !(fromItem instanceof ListRootItem)) {
|
|
||||||
// Event is triggered on source and destination list, only handle it once
|
|
||||||
let fromItems = [];
|
|
||||||
let toItems = [];
|
|
||||||
|
|
||||||
if (Sortable.get(e.from)._waypointRoot) {
|
|
||||||
fromItems = [fromItem.extend('waypoints')];
|
|
||||||
} else {
|
|
||||||
let oldIndices: number[] =
|
|
||||||
e.oldIndicies.length > 0
|
|
||||||
? e.oldIndicies.map((i) => i.index)
|
|
||||||
: [e.oldIndex];
|
|
||||||
oldIndices = oldIndices.filter((i) => i >= 0);
|
|
||||||
oldIndices.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
fromItems = oldIndices.map((i) => fromItem.extend(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Sortable.get(e.from)._waypointRoot && Sortable.get(e.to)._waypointRoot) {
|
|
||||||
toItems = [toItem.extend('waypoints')];
|
|
||||||
} else {
|
|
||||||
if (Sortable.get(e.to)._waypointRoot) {
|
|
||||||
toItem = toItem.extend('waypoints');
|
|
||||||
}
|
|
||||||
|
|
||||||
let newIndices: number[] =
|
|
||||||
e.newIndicies.length > 0
|
|
||||||
? e.newIndicies.map((i) => i.index)
|
|
||||||
: [e.newIndex];
|
|
||||||
newIndices = newIndices.filter((i) => i >= 0);
|
|
||||||
newIndices.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
if (toItem instanceof ListRootItem) {
|
|
||||||
let newFileIds = getFileIds(newIndices.length);
|
|
||||||
toItems = newIndices.map((i, index) => {
|
|
||||||
$fileOrder.splice(i, 0, newFileIds[index]);
|
|
||||||
return item.extend(newFileIds[index]);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toItems = newIndices.map((i) => toItem.extend(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
moveItems(fromItem, toItem, fromItems, toItems);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Object.defineProperty(sortable, '_item', {
|
|
||||||
value: item,
|
|
||||||
writable: true
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(sortable, '_waypointRoot', {
|
|
||||||
value: waypointRoot,
|
|
||||||
writable: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
createSortable();
|
|
||||||
destroyed = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterUpdate(() => {
|
|
||||||
elements = {};
|
|
||||||
container.childNodes.forEach((element) => {
|
|
||||||
if (element instanceof HTMLElement) {
|
|
||||||
let attr = element.getAttribute('data-id');
|
|
||||||
if (attr) {
|
|
||||||
if (node instanceof Map && !node.has(attr)) {
|
|
||||||
element.remove();
|
|
||||||
} else {
|
|
||||||
elements[attr] = element;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
syncFileOrder();
|
|
||||||
updateFromSelection();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -340,11 +107,13 @@
|
|||||||
|
|
||||||
{#if node instanceof GPXFile && item instanceof ListFileItem}
|
{#if node instanceof GPXFile && item instanceof ListFileItem}
|
||||||
{#if !waypointRoot}
|
{#if !waypointRoot}
|
||||||
<svelte:self {node} {item} waypointRoot={true} />
|
<FileListNodeContent {node} {item} waypointRoot={true} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../../app.css";
|
||||||
|
|
||||||
.sortable > div {
|
.sortable > div {
|
||||||
@apply rounded-md;
|
@apply rounded-md;
|
||||||
@apply h-fit;
|
@apply h-fit;
|
||||||
@@ -352,20 +121,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vertical :global(button) {
|
.vertical :global(button) {
|
||||||
@apply hover:bg-muted;
|
@apply hover:bg-[var(--selection)];
|
||||||
}
|
|
||||||
|
|
||||||
.vertical :global(.sortable-selected button) {
|
|
||||||
@apply hover:bg-accent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.vertical :global(.sortable-selected) {
|
.vertical :global(.sortable-selected) {
|
||||||
@apply bg-accent;
|
@apply bg-[var(--selection)];
|
||||||
}
|
}
|
||||||
|
|
||||||
.horizontal :global(button) {
|
.horizontal :global(button) {
|
||||||
@apply bg-accent;
|
@apply bg-[var(--selection)];
|
||||||
@apply hover:bg-muted;
|
@apply hover:bg-background;
|
||||||
}
|
}
|
||||||
|
|
||||||
.horizontal :global(.sortable-selected button) {
|
.horizontal :global(.sortable-selected button) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||||
import { dbUtils, getFile } from '$lib/db';
|
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
Info,
|
Info,
|
||||||
@@ -18,103 +17,107 @@
|
|||||||
Maximize,
|
Maximize,
|
||||||
Scissors,
|
Scissors,
|
||||||
FileStack,
|
FileStack,
|
||||||
FileX
|
} from '@lucide/svelte';
|
||||||
} from 'lucide-svelte';
|
|
||||||
import {
|
import {
|
||||||
ListFileItem,
|
ListFileItem,
|
||||||
ListLevel,
|
ListLevel,
|
||||||
ListTrackItem,
|
ListTrackItem,
|
||||||
ListWaypointItem,
|
ListWaypointItem,
|
||||||
allowedPastes,
|
type ListItem,
|
||||||
type ListItem
|
} from './file-list';
|
||||||
} from './FileList';
|
|
||||||
import {
|
|
||||||
copied,
|
|
||||||
copySelection,
|
|
||||||
cut,
|
|
||||||
cutSelection,
|
|
||||||
pasteSelection,
|
|
||||||
selectAll,
|
|
||||||
selectItem,
|
|
||||||
selection
|
|
||||||
} from './Selection';
|
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import {
|
|
||||||
allHidden,
|
|
||||||
editMetadata,
|
|
||||||
editStyle,
|
|
||||||
embedding,
|
|
||||||
centerMapOnSelection,
|
|
||||||
gpxLayers,
|
|
||||||
map
|
|
||||||
} from '$lib/stores';
|
|
||||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import MetadataDialog from './MetadataDialog.svelte';
|
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
|
||||||
import StyleDialog from './StyleDialog.svelte';
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
import { waypointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte';
|
||||||
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||||
|
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||||
|
import { selection, copied, cut } from '$lib/logic/selection';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
import { fileActions, pasteSelection } from '$lib/logic/file-actions';
|
||||||
|
import { allHidden } from '$lib/logic/hidden';
|
||||||
|
import { boundsManager } from '$lib/logic/bounds';
|
||||||
|
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||||
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
||||||
|
import { allowedPastes } from './sortable-file-list';
|
||||||
|
|
||||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
let {
|
||||||
export let item: ListItem;
|
node,
|
||||||
export let label: string | undefined;
|
item,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||||
|
item: ListItem;
|
||||||
|
label: string | undefined;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||||
|
let embedding = getContext<boolean>('embedding');
|
||||||
|
|
||||||
$: singleSelection = $selection.size === 1;
|
let singleSelection = $derived($selection.size === 1);
|
||||||
|
|
||||||
let nodeColors: string[] = [];
|
let nodeColors: string[] = $state([]);
|
||||||
|
|
||||||
$: if (node && $map) {
|
|
||||||
nodeColors = [];
|
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
let colors: string[] = [];
|
||||||
|
if (node && $map) {
|
||||||
if (node instanceof GPXFile) {
|
if (node instanceof GPXFile) {
|
||||||
let style = node.getStyle();
|
let defaultColor = undefined;
|
||||||
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
let layer = gpxLayers.getLayer(item.getFileId());
|
||||||
if (layer) {
|
if (layer) {
|
||||||
style.color.push(layer.layerColor);
|
defaultColor = layer.layerColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let style = node.getStyle(defaultColor);
|
||||||
style.color.forEach((c) => {
|
style.color.forEach((c) => {
|
||||||
if (!nodeColors.includes(c)) {
|
if (!colors.includes(c)) {
|
||||||
nodeColors.push(c);
|
colors.push(c);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (node instanceof Track) {
|
} else if (node instanceof Track) {
|
||||||
let style = node.getStyle();
|
let style = node.getStyle();
|
||||||
if (style) {
|
if (style) {
|
||||||
if (style.color && !nodeColors.includes(style.color)) {
|
if (style['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) {
|
||||||
nodeColors.push(style.color);
|
colors.push(style['gpx_style:color']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (nodeColors.length === 0) {
|
if (colors.length === 0) {
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
let layer = gpxLayers.getLayer(item.getFileId());
|
||||||
if (layer) {
|
if (layer) {
|
||||||
nodeColors.push(layer.layerColor);
|
colors.push(layer.layerColor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
nodeColors = colors;
|
||||||
|
});
|
||||||
|
|
||||||
let openEditMetadata: boolean = false;
|
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
|
||||||
let openEditStyle: boolean = false;
|
|
||||||
|
|
||||||
$: openEditMetadata = $editMetadata && singleSelection && $selection.has(item);
|
let openEditMetadata: boolean = $derived(
|
||||||
$: openEditStyle =
|
editMetadata.current && singleSelection && $selection.has(item)
|
||||||
$editStyle &&
|
);
|
||||||
|
let openEditStyle: boolean = $derived(
|
||||||
|
editStyle.current &&
|
||||||
$selection.has(item) &&
|
$selection.has(item) &&
|
||||||
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0;
|
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0
|
||||||
$: hidden = item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden;
|
);
|
||||||
|
|
||||||
|
let hidden = $derived(
|
||||||
|
item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<ContextMenu.Root
|
<ContextMenu.Root
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (!get(selection).has(item)) {
|
if (!$selection.has(item)) {
|
||||||
selectItem(item);
|
selection.selectItem(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -122,7 +125,7 @@
|
|||||||
<ContextMenu.Trigger class="grow truncate">
|
<ContextMenu.Trigger class="grow truncate">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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'
|
'vertical'
|
||||||
? 'h-fit'
|
? 'h-fit'
|
||||||
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
|
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
|
||||||
@@ -144,7 +147,7 @@
|
|||||||
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
|
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
|
||||||
)
|
)
|
||||||
.join(',')})"
|
.join(',')})"
|
||||||
/>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<span
|
||||||
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
|
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
|
||||||
@@ -152,8 +155,8 @@
|
|||||||
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
|
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
|
||||||
? 'text-muted-foreground'
|
? 'text-muted-foreground'
|
||||||
: ''}"
|
: ''}"
|
||||||
on:contextmenu={(e) => {
|
oncontextmenu={(e) => {
|
||||||
if ($embedding) {
|
if (embedding) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return;
|
return;
|
||||||
@@ -166,21 +169,24 @@
|
|||||||
$selection = $selection;
|
$selection = $selection;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
on:mouseenter={() => {
|
onmouseenter={() => {
|
||||||
if (item instanceof ListWaypointItem) {
|
if (item instanceof ListWaypointItem) {
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
let layer = gpxLayers.getLayer(item.getFileId());
|
||||||
let file = getFile(item.getFileId());
|
let file = fileStateCollection.getFile(item.getFileId());
|
||||||
if (layer && file) {
|
if (layer && file) {
|
||||||
let waypoint = file.wpt[item.getWaypointIndex()];
|
let waypoint = file.wpt[item.getWaypointIndex()];
|
||||||
if (waypoint) {
|
if (waypoint && !waypoint._data.hidden) {
|
||||||
waypointPopup?.setItem({ item: waypoint, fileId: item.getFileId() });
|
waypointPopup?.setItem({
|
||||||
|
item: waypoint,
|
||||||
|
fileId: item.getFileId(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
on:mouseleave={() => {
|
onmouseleave={() => {
|
||||||
if (item instanceof ListWaypointItem) {
|
if (item instanceof ListWaypointItem) {
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
let layer = gpxLayers.getLayer(item.getFileId());
|
||||||
if (layer) {
|
if (layer) {
|
||||||
waypointPopup?.setItem(null);
|
waypointPopup?.setItem(null);
|
||||||
}
|
}
|
||||||
@@ -188,20 +194,28 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if item.level === ListLevel.SEGMENT}
|
{#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}
|
{:else if item.level === ListLevel.WAYPOINT}
|
||||||
<MapPin size="16" class="mr-1 shrink-0" />
|
{#if symbolKey && symbols[symbolKey].icon}
|
||||||
|
{@const SymbolIcon = symbols[symbolKey].icon}
|
||||||
|
<SymbolIcon size="16" class="mx-1 shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<MapPin size="16" class="mx-1 shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="grow select-none truncate {orientation === 'vertical' ? 'last:mr-2' : ''}">
|
{/if}
|
||||||
|
<span
|
||||||
|
class="grow select-none truncate {orientation === 'vertical'
|
||||||
|
? 'last:mr-2'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{#if hidden}
|
{#if hidden}
|
||||||
<EyeOff
|
<EyeOff
|
||||||
size="12"
|
size="10"
|
||||||
class="shrink-0 mt-1 ml-1 {orientation === 'vertical' ? 'mr-2' : ''} {item.level ===
|
class="shrink-0 size-3.5 ml-1 {orientation === 'vertical'
|
||||||
ListLevel.SEGMENT || item.level === ListLevel.WAYPOINT
|
|
||||||
? 'mr-3'
|
? 'mr-3'
|
||||||
: ''}"
|
: 'mt-0.5'}"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
@@ -209,31 +223,34 @@
|
|||||||
</ContextMenu.Trigger>
|
</ContextMenu.Trigger>
|
||||||
<ContextMenu.Content>
|
<ContextMenu.Content>
|
||||||
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
||||||
<ContextMenu.Item disabled={!singleSelection} on:click={() => ($editMetadata = true)}>
|
<ContextMenu.Item
|
||||||
<Info size="16" class="mr-1" />
|
disabled={!singleSelection}
|
||||||
{$_('menu.metadata.button')}
|
onclick={() => (editMetadata.current = true)}
|
||||||
|
>
|
||||||
|
<Info size="16" />
|
||||||
|
{i18n._('menu.metadata.button')}
|
||||||
<Shortcut key="I" ctrl={true} />
|
<Shortcut key="I" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Item on:click={() => ($editStyle = true)}>
|
<ContextMenu.Item onclick={() => (editStyle.current = true)}>
|
||||||
<PaintBucket size="16" class="mr-1" />
|
<PaintBucket size="16" />
|
||||||
{$_('menu.style.button')}
|
{i18n._('menu.style.button')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
if ($allHidden) {
|
if ($allHidden) {
|
||||||
dbUtils.setHiddenToSelection(false);
|
fileActions.setHiddenToSelection(false);
|
||||||
} else {
|
} else {
|
||||||
dbUtils.setHiddenToSelection(true);
|
fileActions.setHiddenToSelection(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if $allHidden}
|
{#if $allHidden}
|
||||||
<Eye size="16" class="mr-1" />
|
<Eye size="16" />
|
||||||
{$_('menu.unhide')}
|
{i18n._('menu.unhide')}
|
||||||
{:else}
|
{:else}
|
||||||
<EyeOff size="16" class="mr-1" />
|
<EyeOff size="16" />
|
||||||
{$_('menu.hide')}
|
{i18n._('menu.hide')}
|
||||||
{/if}
|
{/if}
|
||||||
<Shortcut key="H" ctrl={true} />
|
<Shortcut key="H" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
@@ -242,72 +259,68 @@
|
|||||||
{#if item instanceof ListFileItem}
|
{#if item instanceof ListFileItem}
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
disabled={!singleSelection}
|
disabled={!singleSelection}
|
||||||
on:click={() => dbUtils.addNewTrack(item.getFileId())}
|
onclick={() => fileActions.addNewTrack(item.getFileId())}
|
||||||
>
|
>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" />
|
||||||
{$_('menu.new_track')}
|
{i18n._('menu.new_track')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
{:else if item instanceof ListTrackItem}
|
{:else if item instanceof ListTrackItem}
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
disabled={!singleSelection}
|
disabled={!singleSelection}
|
||||||
on:click={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())}
|
onclick={() =>
|
||||||
|
fileActions.addNewSegment(item.getFileId(), item.getTrackIndex())}
|
||||||
>
|
>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" />
|
||||||
{$_('menu.new_segment')}
|
{i18n._('menu.new_segment')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.level !== ListLevel.WAYPOINTS}
|
{#if item.level !== ListLevel.WAYPOINTS}
|
||||||
<ContextMenu.Item on:click={selectAll}>
|
<ContextMenu.Item onclick={() => selection.selectAll()}>
|
||||||
<FileStack size="16" class="mr-1" />
|
<FileStack size="16" />
|
||||||
{$_('menu.select_all')}
|
{i18n._('menu.select_all')}
|
||||||
<Shortcut key="A" ctrl={true} />
|
<Shortcut key="A" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenu.Item on:click={centerMapOnSelection}>
|
<ContextMenu.Item onclick={() => boundsManager.centerMapOnSelection()}>
|
||||||
<Maximize size="16" class="mr-1" />
|
<Maximize size="16" />
|
||||||
{$_('menu.center')}
|
{i18n._('menu.center')}
|
||||||
<Shortcut key="⏎" ctrl={true} />
|
<Shortcut key="⏎" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
|
<ContextMenu.Item onclick={fileActions.duplicateSelection}>
|
||||||
<Copy size="16" class="mr-1" />
|
<Copy size="16" />
|
||||||
{$_('menu.duplicate')}
|
{i18n._('menu.duplicate')}
|
||||||
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
|
<Shortcut key="D" ctrl={true} />
|
||||||
>
|
</ContextMenu.Item>
|
||||||
{#if orientation === 'vertical'}
|
{#if orientation === 'vertical'}
|
||||||
<ContextMenu.Item on:click={copySelection}>
|
<ContextMenu.Item onclick={() => selection.copySelection()}>
|
||||||
<ClipboardCopy size="16" class="mr-1" />
|
<ClipboardCopy size="16" />
|
||||||
{$_('menu.copy')}
|
{i18n._('menu.copy')}
|
||||||
<Shortcut key="C" ctrl={true} />
|
<Shortcut key="C" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Item on:click={cutSelection}>
|
<ContextMenu.Item onclick={() => selection.cutSelection()}>
|
||||||
<Scissors size="16" class="mr-1" />
|
<Scissors size="16" />
|
||||||
{$_('menu.cut')}
|
{i18n._('menu.cut')}
|
||||||
<Shortcut key="X" ctrl={true} />
|
<Shortcut key="X" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
disabled={$copied === undefined ||
|
disabled={$copied === undefined ||
|
||||||
$copied.length === 0 ||
|
$copied.length === 0 ||
|
||||||
!allowedPastes[$copied[0].level].includes(item.level)}
|
!allowedPastes[$copied[0].level].includes(item.level)}
|
||||||
on:click={pasteSelection}
|
onclick={pasteSelection}
|
||||||
>
|
>
|
||||||
<ClipboardPaste size="16" class="mr-1" />
|
<ClipboardPaste size="16" />
|
||||||
{$_('menu.paste')}
|
{i18n._('menu.paste')}
|
||||||
<Shortcut key="V" ctrl={true} />
|
<Shortcut key="V" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
<ContextMenu.Item on:click={dbUtils.deleteSelection}>
|
<ContextMenu.Item onclick={fileActions.deleteSelection}>
|
||||||
{#if item instanceof ListFileItem}
|
<Trash2 size="16" />
|
||||||
<FileX size="16" class="mr-1" />
|
{i18n._('menu.delete')}
|
||||||
{$_('menu.close')}
|
|
||||||
{:else}
|
|
||||||
<Trash2 size="16" class="mr-1" />
|
|
||||||
{$_('menu.delete')}
|
|
||||||
{/if}
|
|
||||||
<Shortcut key="⌫" ctrl={true} />
|
<Shortcut key="⌫" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
|
|||||||
@@ -2,12 +2,16 @@
|
|||||||
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
||||||
import FileListNode from '$lib/components/file-list/FileListNode.svelte';
|
import FileListNode from '$lib/components/file-list/FileListNode.svelte';
|
||||||
|
|
||||||
import type { GPXFileWithStatistics } from '$lib/db';
|
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import type { Readable } from 'svelte/store';
|
import type { Readable } from 'svelte/store';
|
||||||
import { ListFileItem } from './FileList';
|
import { ListFileItem } from './file-list';
|
||||||
|
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||||
|
|
||||||
export let file: Readable<GPXFileWithStatistics | undefined>;
|
let {
|
||||||
|
file,
|
||||||
|
}: {
|
||||||
|
file: Readable<GPXFileWithStatistics | undefined>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let recursive = getContext<boolean>('recursive');
|
let recursive = getContext<boolean>('recursive');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,315 +0,0 @@
|
|||||||
import { get, writable } from "svelte/store";
|
|
||||||
import { ListFileItem, ListItem, ListRootItem, ListTrackItem, ListTrackSegmentItem, ListWaypointItem, ListLevel, sortItems, ListWaypointsItem, moveItems } from "./FileList";
|
|
||||||
import { fileObservers, getFile, getFileIds, settings } from "$lib/db";
|
|
||||||
|
|
||||||
export class SelectionTreeType {
|
|
||||||
item: ListItem;
|
|
||||||
selected: boolean;
|
|
||||||
children: {
|
|
||||||
[key: string | number]: SelectionTreeType
|
|
||||||
};
|
|
||||||
size: number = 0;
|
|
||||||
|
|
||||||
constructor(item: ListItem) {
|
|
||||||
this.item = item;
|
|
||||||
this.selected = false;
|
|
||||||
this.children = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.selected = false;
|
|
||||||
for (let key in this.children) {
|
|
||||||
this.children[key].clear();
|
|
||||||
}
|
|
||||||
this.size = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
_setOrToggle(item: ListItem, value?: boolean) {
|
|
||||||
if (item.level === this.item.level) {
|
|
||||||
let newSelected = value === undefined ? !this.selected : value;
|
|
||||||
if (this.selected !== newSelected) {
|
|
||||||
this.selected = newSelected;
|
|
||||||
this.size += this.selected ? 1 : -1;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let id = item.getIdAtLevel(this.item.level);
|
|
||||||
if (id !== undefined) {
|
|
||||||
if (!this.children.hasOwnProperty(id)) {
|
|
||||||
this.children[id] = new SelectionTreeType(this.item.extend(id));
|
|
||||||
}
|
|
||||||
this.size -= this.children[id].size;
|
|
||||||
this.children[id]._setOrToggle(item, value);
|
|
||||||
this.size += this.children[id].size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
set(item: ListItem, value: boolean) {
|
|
||||||
this._setOrToggle(item, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle(item: ListItem) {
|
|
||||||
this._setOrToggle(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
has(item: ListItem): boolean {
|
|
||||||
if (item.level === this.item.level) {
|
|
||||||
return this.selected;
|
|
||||||
} else {
|
|
||||||
let id = item.getIdAtLevel(this.item.level);
|
|
||||||
if (id !== undefined) {
|
|
||||||
if (this.children.hasOwnProperty(id)) {
|
|
||||||
return this.children[id].has(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasAnyParent(item: ListItem, self: boolean = true): boolean {
|
|
||||||
if (this.selected && this.item.level <= item.level && (self || this.item.level < item.level)) {
|
|
||||||
return this.selected;
|
|
||||||
}
|
|
||||||
let id = item.getIdAtLevel(this.item.level);
|
|
||||||
if (id !== undefined) {
|
|
||||||
if (this.children.hasOwnProperty(id)) {
|
|
||||||
return this.children[id].hasAnyParent(item, self);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
|
|
||||||
if (this.selected && this.item.level >= item.level && (self || this.item.level > item.level)) {
|
|
||||||
return this.selected;
|
|
||||||
}
|
|
||||||
let id = item.getIdAtLevel(this.item.level);
|
|
||||||
if (id !== undefined) {
|
|
||||||
if (ignoreIds === undefined || ignoreIds.indexOf(id) === -1) {
|
|
||||||
if (this.children.hasOwnProperty(id)) {
|
|
||||||
return this.children[id].hasAnyChildren(item, self, ignoreIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let key in this.children) {
|
|
||||||
if (ignoreIds === undefined || ignoreIds.indexOf(key) === -1) {
|
|
||||||
if (this.children[key].hasAnyChildren(item, self, ignoreIds)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSelected(selection: ListItem[] = []): ListItem[] {
|
|
||||||
if (this.selected) {
|
|
||||||
selection.push(this.item);
|
|
||||||
}
|
|
||||||
for (let key in this.children) {
|
|
||||||
this.children[key].getSelected(selection);
|
|
||||||
}
|
|
||||||
return selection;
|
|
||||||
}
|
|
||||||
|
|
||||||
forEach(callback: (item: ListItem) => void) {
|
|
||||||
if (this.selected) {
|
|
||||||
callback(this.item);
|
|
||||||
}
|
|
||||||
for (let key in this.children) {
|
|
||||||
this.children[key].forEach(callback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getChild(id: string | number): SelectionTreeType | undefined {
|
|
||||||
return this.children[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteChild(id: string | number) {
|
|
||||||
if (this.children.hasOwnProperty(id)) {
|
|
||||||
this.size -= this.children[id].size;
|
|
||||||
delete this.children[id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const selection = writable<SelectionTreeType>(new SelectionTreeType(new ListRootItem()));
|
|
||||||
|
|
||||||
export function selectItem(item: ListItem) {
|
|
||||||
selection.update(($selection) => {
|
|
||||||
$selection.clear();
|
|
||||||
$selection.set(item, true);
|
|
||||||
return $selection;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectFile(fileId: string) {
|
|
||||||
selectItem(new ListFileItem(fileId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addSelectItem(item: ListItem) {
|
|
||||||
selection.update(($selection) => {
|
|
||||||
$selection.toggle(item);
|
|
||||||
return $selection;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addSelectFile(fileId: string) {
|
|
||||||
addSelectItem(new ListFileItem(fileId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectAll() {
|
|
||||||
selection.update(($selection) => {
|
|
||||||
let item: ListItem = new ListRootItem();
|
|
||||||
$selection.forEach((i) => {
|
|
||||||
item = i;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (item instanceof ListRootItem || item instanceof ListFileItem) {
|
|
||||||
$selection.clear();
|
|
||||||
get(fileObservers).forEach((_file, fileId) => {
|
|
||||||
$selection.set(new ListFileItem(fileId), true);
|
|
||||||
});
|
|
||||||
} else if (item instanceof ListTrackItem) {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
if (file) {
|
|
||||||
file.trk.forEach((_track, trackId) => {
|
|
||||||
$selection.set(new ListTrackItem(item.getFileId(), trackId), true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (item instanceof ListTrackSegmentItem) {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
if (file) {
|
|
||||||
file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => {
|
|
||||||
$selection.set(new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId), true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (item instanceof ListWaypointItem) {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
if (file) {
|
|
||||||
file.wpt.forEach((_waypoint, waypointId) => {
|
|
||||||
$selection.set(new ListWaypointItem(item.getFileId(), waypointId), true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $selection;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOrderedSelection(reverse: boolean = false): ListItem[] {
|
|
||||||
let selected: ListItem[] = [];
|
|
||||||
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
|
||||||
selected.push(...items);
|
|
||||||
}, reverse);
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
|
|
||||||
get(settings.fileOrder).forEach((fileId) => {
|
|
||||||
let level: ListLevel | undefined = undefined;
|
|
||||||
let items: ListItem[] = [];
|
|
||||||
selectedItems.forEach((item) => {
|
|
||||||
if (item.getFileId() === fileId) {
|
|
||||||
level = item.level;
|
|
||||||
if (item instanceof ListFileItem || item instanceof ListTrackItem || item instanceof ListTrackSegmentItem || item instanceof ListWaypointsItem || item instanceof ListWaypointItem) {
|
|
||||||
items.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (items.length > 0) {
|
|
||||||
sortItems(items, reverse);
|
|
||||||
callback(fileId, level, items);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyToOrderedSelectedItemsFromFile(callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
|
|
||||||
applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const copied = writable<ListItem[] | undefined>(undefined);
|
|
||||||
export const cut = writable(false);
|
|
||||||
|
|
||||||
export function copySelection(): boolean {
|
|
||||||
let selected = get(selection).getSelected();
|
|
||||||
if (selected.length > 0) {
|
|
||||||
copied.set(selected);
|
|
||||||
cut.set(false);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cutSelection() {
|
|
||||||
if (copySelection()) {
|
|
||||||
cut.set(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetCopied() {
|
|
||||||
copied.set(undefined);
|
|
||||||
cut.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pasteSelection() {
|
|
||||||
let fromItems = get(copied);
|
|
||||||
if (fromItems === undefined || fromItems.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let selected = get(selection).getSelected();
|
|
||||||
if (selected.length === 0) {
|
|
||||||
selected = [new ListRootItem()];
|
|
||||||
}
|
|
||||||
|
|
||||||
let fromParent = fromItems[0].getParent();
|
|
||||||
let toParent = selected[selected.length - 1];
|
|
||||||
|
|
||||||
let startIndex: number | undefined = undefined;
|
|
||||||
|
|
||||||
if (fromItems[0].level === toParent.level) {
|
|
||||||
if (toParent instanceof ListTrackItem || toParent instanceof ListTrackSegmentItem || toParent instanceof ListWaypointItem) {
|
|
||||||
startIndex = toParent.getId() + 1;
|
|
||||||
}
|
|
||||||
toParent = toParent.getParent();
|
|
||||||
}
|
|
||||||
|
|
||||||
let toItems: ListItem[] = [];
|
|
||||||
if (toParent.level === ListLevel.ROOT) {
|
|
||||||
let fileIds = getFileIds(fromItems.length);
|
|
||||||
fileIds.forEach((fileId) => {
|
|
||||||
toItems.push(new ListFileItem(fileId));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let toFile = getFile(toParent.getFileId());
|
|
||||||
if (toFile) {
|
|
||||||
fromItems.forEach((item, index) => {
|
|
||||||
if (toParent instanceof ListFileItem) {
|
|
||||||
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
|
|
||||||
toItems.push(new ListTrackItem(toParent.getFileId(), (startIndex ?? toFile.trk.length) + index));
|
|
||||||
} else if (item instanceof ListWaypointsItem) {
|
|
||||||
toItems.push(new ListWaypointsItem(toParent.getFileId()));
|
|
||||||
} else if (item instanceof ListWaypointItem) {
|
|
||||||
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
|
|
||||||
}
|
|
||||||
} else if (toParent instanceof ListTrackItem) {
|
|
||||||
if (item instanceof ListTrackSegmentItem) {
|
|
||||||
let toTrackIndex = toParent.getTrackIndex();
|
|
||||||
toItems.push(new ListTrackSegmentItem(toParent.getFileId(), toTrackIndex, (startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index));
|
|
||||||
}
|
|
||||||
} else if (toParent instanceof ListWaypointsItem) {
|
|
||||||
if (item instanceof ListWaypointItem) {
|
|
||||||
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fromItems.length === toItems.length) {
|
|
||||||
moveItems(fromParent, toParent, fromItems, toItems, get(cut));
|
|
||||||
resetCopied();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
|
||||||
import { Slider } from '$lib/components/ui/slider';
|
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
|
||||||
import { dbUtils, getFile, settings } from '$lib/db';
|
|
||||||
import { Save } from 'lucide-svelte';
|
|
||||||
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
|
|
||||||
import { selection } from './Selection';
|
|
||||||
import { editStyle, gpxLayers } from '$lib/stores';
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
|
|
||||||
export let item: ListItem;
|
|
||||||
export let open = false;
|
|
||||||
|
|
||||||
const { defaultOpacity, defaultWeight } = settings;
|
|
||||||
|
|
||||||
let colors: string[] = [];
|
|
||||||
let color: string | undefined = undefined;
|
|
||||||
let opacity: number[] = [];
|
|
||||||
let weight: number[] = [];
|
|
||||||
let colorChanged = false;
|
|
||||||
let opacityChanged = false;
|
|
||||||
let weightChanged = false;
|
|
||||||
|
|
||||||
function setStyleInputs() {
|
|
||||||
colors = [];
|
|
||||||
opacity = [];
|
|
||||||
weight = [];
|
|
||||||
|
|
||||||
$selection.forEach((item) => {
|
|
||||||
if (item instanceof ListFileItem) {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
|
||||||
if (file && layer) {
|
|
||||||
let style = file.getStyle();
|
|
||||||
style.color.push(layer.layerColor);
|
|
||||||
|
|
||||||
style.color.forEach((c) => {
|
|
||||||
if (!colors.includes(c)) {
|
|
||||||
colors.push(c);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
style.opacity.forEach((o) => {
|
|
||||||
if (!opacity.includes(o)) {
|
|
||||||
opacity.push(o);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
style.weight.forEach((w) => {
|
|
||||||
if (!weight.includes(w)) {
|
|
||||||
weight.push(w);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (item instanceof ListTrackItem) {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
|
||||||
if (file && layer) {
|
|
||||||
let track = file.trk[item.getTrackIndex()];
|
|
||||||
let style = track.getStyle();
|
|
||||||
if (style) {
|
|
||||||
if (style.color && !colors.includes(style.color)) {
|
|
||||||
colors.push(style.color);
|
|
||||||
}
|
|
||||||
if (style.opacity && !opacity.includes(style.opacity)) {
|
|
||||||
opacity.push(style.opacity);
|
|
||||||
}
|
|
||||||
if (style.weight && !weight.includes(style.weight)) {
|
|
||||||
weight.push(style.weight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!colors.includes(layer.layerColor)) {
|
|
||||||
colors.push(layer.layerColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
color = colors[0];
|
|
||||||
opacity = [opacity[0] ?? $defaultOpacity];
|
|
||||||
weight = [weight[0] ?? $defaultWeight];
|
|
||||||
|
|
||||||
colorChanged = false;
|
|
||||||
opacityChanged = false;
|
|
||||||
weightChanged = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($selection && open) {
|
|
||||||
setStyleInputs();
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (!open) {
|
|
||||||
$editStyle = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Popover.Root bind:open>
|
|
||||||
<Popover.Trigger />
|
|
||||||
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
|
||||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
|
||||||
{$_('menu.style.color')}
|
|
||||||
<Input
|
|
||||||
bind:value={color}
|
|
||||||
type="color"
|
|
||||||
class="p-0 h-6 w-40"
|
|
||||||
on:change={() => (colorChanged = true)}
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
|
||||||
{$_('menu.style.opacity')}
|
|
||||||
<div class="w-40 p-2">
|
|
||||||
<Slider
|
|
||||||
bind:value={opacity}
|
|
||||||
min={0.3}
|
|
||||||
max={1}
|
|
||||||
step={0.1}
|
|
||||||
onValueChange={() => (opacityChanged = true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
|
||||||
{$_('menu.style.width')}
|
|
||||||
<div class="w-40 p-2">
|
|
||||||
<Slider
|
|
||||||
bind:value={weight}
|
|
||||||
id="weight"
|
|
||||||
min={1}
|
|
||||||
max={10}
|
|
||||||
step={1}
|
|
||||||
onValueChange={() => (weightChanged = true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={!colorChanged && !opacityChanged && !weightChanged}
|
|
||||||
on:click={() => {
|
|
||||||
let style = {};
|
|
||||||
if (colorChanged) {
|
|
||||||
style.color = color;
|
|
||||||
}
|
|
||||||
if (opacityChanged) {
|
|
||||||
style.opacity = opacity[0];
|
|
||||||
}
|
|
||||||
if (weightChanged) {
|
|
||||||
style.weight = weight[0];
|
|
||||||
}
|
|
||||||
dbUtils.setStyleToSelection(style);
|
|
||||||
|
|
||||||
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
|
||||||
if (style.opacity) {
|
|
||||||
$defaultOpacity = style.opacity;
|
|
||||||
}
|
|
||||||
if (style.weight) {
|
|
||||||
$defaultWeight = style.weight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Save size="16" class="mr-1" />
|
|
||||||
{$_('menu.metadata.save')}
|
|
||||||
</Button>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
@@ -1,37 +1,14 @@
|
|||||||
import { dbUtils, getFile } from "$lib/db";
|
|
||||||
import { freeze } from "immer";
|
|
||||||
import { GPXFile, Track, TrackSegment, Waypoint } from "gpx";
|
|
||||||
import { selection } from "./Selection";
|
|
||||||
import { newGPXFile } from "$lib/stores";
|
|
||||||
|
|
||||||
export enum ListLevel {
|
export enum ListLevel {
|
||||||
ROOT,
|
ROOT,
|
||||||
FILE,
|
FILE,
|
||||||
TRACK,
|
TRACK,
|
||||||
SEGMENT,
|
SEGMENT,
|
||||||
WAYPOINTS,
|
WAYPOINTS,
|
||||||
WAYPOINT
|
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 {
|
export abstract class ListItem {
|
||||||
|
[x: string]: any;
|
||||||
level: ListLevel;
|
level: ListLevel;
|
||||||
|
|
||||||
constructor(level: ListLevel) {
|
constructor(level: ListLevel) {
|
||||||
@@ -321,120 +298,3 @@ export function sortItems(items: ListItem[], reverse: boolean = false) {
|
|||||||
items.reverse();
|
items.reverse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: ListItem[], toItems: ListItem[], remove: boolean = true) {
|
|
||||||
if (fromItems.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sortItems(fromItems, false);
|
|
||||||
sortItems(toItems, false);
|
|
||||||
|
|
||||||
let context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[] = [];
|
|
||||||
fromItems.forEach((item) => {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
if (file) {
|
|
||||||
if (item instanceof ListFileItem) {
|
|
||||||
context.push(file.clone());
|
|
||||||
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
|
|
||||||
context.push(file.trk[item.getTrackIndex()].clone());
|
|
||||||
} else if (item instanceof ListTrackSegmentItem && item.getTrackIndex() < file.trk.length && item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length) {
|
|
||||||
context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone());
|
|
||||||
} else if (item instanceof ListWaypointsItem) {
|
|
||||||
context.push(file.wpt.map((wpt) => wpt.clone()));
|
|
||||||
} else if (item instanceof ListWaypointItem && item.getWaypointIndex() < file.wpt.length) {
|
|
||||||
context.push(file.wpt[item.getWaypointIndex()].clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (remove && !(fromParent instanceof ListRootItem)) {
|
|
||||||
sortItems(fromItems, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
let files = [fromParent.getFileId(), toParent.getFileId()];
|
|
||||||
let callbacks = [
|
|
||||||
(file, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
|
||||||
fromItems.forEach((item) => {
|
|
||||||
if (item instanceof ListTrackItem) {
|
|
||||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
|
|
||||||
} else if (item instanceof ListTrackSegmentItem) {
|
|
||||||
file.replaceTrackSegments(item.getTrackIndex(), item.getSegmentIndex(), item.getSegmentIndex(), []);
|
|
||||||
} else if (item instanceof ListWaypointsItem) {
|
|
||||||
file.replaceWaypoints(0, file.wpt.length - 1, []);
|
|
||||||
} else if (item instanceof ListWaypointItem) {
|
|
||||||
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex(), []);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
(file, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
|
||||||
toItems.forEach((item, i) => {
|
|
||||||
if (item instanceof ListTrackItem) {
|
|
||||||
if (context[i] instanceof Track) {
|
|
||||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [context[i]]);
|
|
||||||
} else if (context[i] instanceof TrackSegment) {
|
|
||||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [new Track({
|
|
||||||
trkseg: [context[i]]
|
|
||||||
})]);
|
|
||||||
}
|
|
||||||
} else if (item instanceof ListTrackSegmentItem && context[i] instanceof TrackSegment) {
|
|
||||||
file.replaceTrackSegments(item.getTrackIndex(), item.getSegmentIndex(), item.getSegmentIndex() - 1, [context[i]]);
|
|
||||||
} else if (item instanceof ListWaypointsItem) {
|
|
||||||
if (Array.isArray(context[i]) && context[i].length > 0 && context[i][0] instanceof Waypoint) {
|
|
||||||
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, context[i]);
|
|
||||||
} else if (context[i] instanceof Waypoint) {
|
|
||||||
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, [context[i]]);
|
|
||||||
}
|
|
||||||
} else if (item instanceof ListWaypointItem && context[i] instanceof Waypoint) {
|
|
||||||
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [context[i]]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
if (fromParent instanceof ListRootItem) {
|
|
||||||
files = [];
|
|
||||||
callbacks = [];
|
|
||||||
} else if (!remove) {
|
|
||||||
files.splice(0, 1);
|
|
||||||
callbacks.splice(0, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
dbUtils.applyEachToFilesAndGlobal(files, callbacks, (files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
|
||||||
toItems.forEach((item, i) => {
|
|
||||||
if (item instanceof ListFileItem) {
|
|
||||||
if (context[i] instanceof GPXFile) {
|
|
||||||
let newFile = context[i];
|
|
||||||
if (remove) {
|
|
||||||
files.delete(newFile._data.id);
|
|
||||||
}
|
|
||||||
newFile._data.id = item.getFileId();
|
|
||||||
files.set(item.getFileId(), freeze(newFile));
|
|
||||||
} else if (context[i] instanceof Track) {
|
|
||||||
let newFile = newGPXFile();
|
|
||||||
newFile._data.id = item.getFileId();
|
|
||||||
if (context[i].name) {
|
|
||||||
newFile.metadata.name = context[i].name;
|
|
||||||
}
|
|
||||||
newFile.replaceTracks(0, 0, [context[i]]);
|
|
||||||
files.set(item.getFileId(), freeze(newFile));
|
|
||||||
} else if (context[i] instanceof TrackSegment) {
|
|
||||||
let newFile = newGPXFile();
|
|
||||||
newFile._data.id = item.getFileId();
|
|
||||||
newFile.replaceTracks(0, 0, [new Track({
|
|
||||||
trkseg: [context[i]]
|
|
||||||
})]);
|
|
||||||
files.set(item.getFileId(), freeze(newFile));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, context);
|
|
||||||
|
|
||||||
selection.update(($selection) => {
|
|
||||||
$selection.clear();
|
|
||||||
toItems.forEach((item) => {
|
|
||||||
$selection.set(item, true);
|
|
||||||
});
|
|
||||||
return $selection;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -4,46 +4,56 @@
|
|||||||
import { Textarea } from '$lib/components/ui/textarea';
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
import { dbUtils } from '$lib/db';
|
import { Save } from '@lucide/svelte';
|
||||||
import { Save } from 'lucide-svelte';
|
import { ListFileItem, ListTrackItem, type ListItem } from '../file-list';
|
||||||
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
|
|
||||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { editMetadata } from '$lib/stores';
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
|
import { fileActionManager } from '$lib/logic/file-action-manager';
|
||||||
|
|
||||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
let {
|
||||||
export let item: ListItem;
|
node,
|
||||||
export let open = false;
|
item,
|
||||||
|
open = $bindable(),
|
||||||
|
}: {
|
||||||
|
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||||
|
item: ListItem;
|
||||||
|
open: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let name: string =
|
let name: string = $derived(
|
||||||
node instanceof GPXFile
|
node instanceof GPXFile
|
||||||
? node.metadata.name ?? ''
|
? (node.metadata.name ?? '')
|
||||||
: node instanceof Track
|
: node instanceof Track
|
||||||
? node.name ?? ''
|
? (node.name ?? '')
|
||||||
: '';
|
: ''
|
||||||
let description: string =
|
);
|
||||||
|
let description: string = $derived(
|
||||||
node instanceof GPXFile
|
node instanceof GPXFile
|
||||||
? node.metadata.desc ?? ''
|
? (node.metadata.desc ?? '')
|
||||||
: node instanceof Track
|
: node instanceof Track
|
||||||
? node.desc ?? ''
|
? (node.desc ?? '')
|
||||||
: '';
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
$: if (!open) {
|
$effect(() => {
|
||||||
$editMetadata = false;
|
if (!open) {
|
||||||
|
editMetadata.current = false;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Popover.Root bind:open>
|
<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">
|
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
||||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
<Label for="name">{i18n._('menu.metadata.name')}</Label>
|
||||||
<Input bind:value={name} id="name" class="font-semibold h-8" />
|
<Input bind:value={name} id="name" class="font-semibold h-8" />
|
||||||
<Label for="description">{$_('menu.metadata.description')}</Label>
|
<Label for="description">{i18n._('menu.metadata.description')}</Label>
|
||||||
<Textarea bind:value={description} id="description" />
|
<Textarea bind:value={description} id="description" />
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
dbUtils.applyToFile(item.getFileId(), (file) => {
|
fileActionManager.applyToFile(item.getFileId(), (file) => {
|
||||||
if (item instanceof ListFileItem && node instanceof GPXFile) {
|
if (item instanceof ListFileItem && node instanceof GPXFile) {
|
||||||
file.metadata.name = name;
|
file.metadata.name = name;
|
||||||
file.metadata.desc = description;
|
file.metadata.desc = description;
|
||||||
@@ -58,8 +68,8 @@
|
|||||||
open = false;
|
open = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Save size="16" class="mr-1" />
|
<Save size="16" />
|
||||||
{$_('menu.metadata.save')}
|
{i18n._('menu.metadata.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const editMetadata = $state({
|
||||||
|
current: false,
|
||||||
|
});
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
website/src/lib/components/file-list/style/StyleDialog.svelte
Normal file
171
website/src/lib/components/file-list/style/StyleDialog.svelte
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import { Slider } from '$lib/components/ui/slider';
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
import { Save } from '@lucide/svelte';
|
||||||
|
import {
|
||||||
|
ListFileItem,
|
||||||
|
ListTrackItem,
|
||||||
|
type ListItem,
|
||||||
|
} from '$lib/components/file-list/file-list';
|
||||||
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import type { LineStyleExtension } from 'gpx';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { selection } from '$lib/logic/selection';
|
||||||
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { fileActions } from '$lib/logic/file-actions';
|
||||||
|
|
||||||
|
let {
|
||||||
|
item,
|
||||||
|
open = $bindable(),
|
||||||
|
}: {
|
||||||
|
item: ListItem;
|
||||||
|
open: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const { defaultOpacity, defaultWidth } = settings;
|
||||||
|
|
||||||
|
let color: string = $state('');
|
||||||
|
let opacity: number = $state(0);
|
||||||
|
let width: number = $state(0);
|
||||||
|
let colorChanged = $state(false);
|
||||||
|
let opacityChanged = $state(false);
|
||||||
|
let widthChanged = $state(false);
|
||||||
|
|
||||||
|
function setStyleInputs() {
|
||||||
|
opacity = $defaultOpacity;
|
||||||
|
width = $defaultWidth;
|
||||||
|
|
||||||
|
$selection.forEach((item) => {
|
||||||
|
if (item instanceof ListFileItem) {
|
||||||
|
let file = fileStateCollection.getFile(item.getFileId());
|
||||||
|
let layer = gpxLayers.getLayer(item.getFileId());
|
||||||
|
if (file && layer) {
|
||||||
|
let style = file.getStyle();
|
||||||
|
color = layer.layerColor;
|
||||||
|
if (style.opacity.length > 0) {
|
||||||
|
opacity = style.opacity[0];
|
||||||
|
}
|
||||||
|
if (style.width.length > 0) {
|
||||||
|
width = style.width[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (item instanceof ListTrackItem) {
|
||||||
|
let file = fileStateCollection.getFile(item.getFileId());
|
||||||
|
let layer = gpxLayers.getLayer(item.getFileId());
|
||||||
|
if (file && layer) {
|
||||||
|
color = layer.layerColor;
|
||||||
|
let track = file.trk[item.getTrackIndex()];
|
||||||
|
let style = track.getStyle();
|
||||||
|
if (style) {
|
||||||
|
if (style['gpx_style:color']) {
|
||||||
|
color = style['gpx_style:color'];
|
||||||
|
}
|
||||||
|
if (style['gpx_style:opacity']) {
|
||||||
|
opacity = style['gpx_style:opacity'];
|
||||||
|
}
|
||||||
|
if (style['gpx_style:width']) {
|
||||||
|
width = style['gpx_style:width'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
colorChanged = false;
|
||||||
|
opacityChanged = false;
|
||||||
|
widthChanged = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($selection && open) {
|
||||||
|
untrack(() => setStyleInputs());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
editStyle.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyStyle() {
|
||||||
|
let style: LineStyleExtension = {};
|
||||||
|
if (colorChanged) {
|
||||||
|
style['gpx_style:color'] = color;
|
||||||
|
}
|
||||||
|
if (opacityChanged) {
|
||||||
|
style['gpx_style:opacity'] = opacity;
|
||||||
|
}
|
||||||
|
if (widthChanged) {
|
||||||
|
style['gpx_style:width'] = width;
|
||||||
|
}
|
||||||
|
fileActions.setStyleToSelection(style);
|
||||||
|
|
||||||
|
if (item instanceof ListFileItem && $selection.size === fileStateCollection.size) {
|
||||||
|
if (style['gpx_style:opacity']) {
|
||||||
|
$defaultOpacity = style['gpx_style:opacity'];
|
||||||
|
}
|
||||||
|
if (style['gpx_style:width']) {
|
||||||
|
$defaultWidth = style['gpx_style:width'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root bind:open>
|
||||||
|
<Popover.Trigger class="-mx-1" />
|
||||||
|
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
||||||
|
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||||
|
{i18n._('menu.style.color')}
|
||||||
|
<Input
|
||||||
|
bind:value={color}
|
||||||
|
type="color"
|
||||||
|
class="p-0 h-6 w-40"
|
||||||
|
onchange={() => (colorChanged = true)}
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||||
|
{i18n._('menu.style.opacity')}
|
||||||
|
<div class="w-40 p-2">
|
||||||
|
<Slider
|
||||||
|
bind:value={opacity}
|
||||||
|
min={0.3}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
onValueChange={() => (opacityChanged = true)}
|
||||||
|
type="single"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||||
|
{i18n._('menu.style.width')}
|
||||||
|
<div class="w-40 p-2">
|
||||||
|
<Slider
|
||||||
|
bind:value={width}
|
||||||
|
id="width"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
onValueChange={() => (widthChanged = true)}
|
||||||
|
type="single"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!colorChanged && !opacityChanged && !widthChanged}
|
||||||
|
onclick={applyStyle}
|
||||||
|
>
|
||||||
|
<Save size="16" />
|
||||||
|
{i18n._('menu.metadata.save')}
|
||||||
|
</Button>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const editStyle = $state({
|
||||||
|
current: false,
|
||||||
|
});
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
|
|
||||||
import { settings } from "$lib/db";
|
|
||||||
import { gpxStatistics } from "$lib/stores";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
|
|
||||||
const { distanceMarkers, distanceUnits } = settings;
|
|
||||||
|
|
||||||
const stops = [[100, 0], [50, 7], [25, 8, 10], [10, 10], [5, 11], [1, 13]];
|
|
||||||
|
|
||||||
export class DistanceMarkers {
|
|
||||||
map: mapboxgl.Map;
|
|
||||||
updateBinded: () => void = this.update.bind(this);
|
|
||||||
unsubscribes: (() => void)[] = [];
|
|
||||||
|
|
||||||
constructor(map: mapboxgl.Map) {
|
|
||||||
this.map = map;
|
|
||||||
|
|
||||||
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
|
|
||||||
this.unsubscribes.push(distanceMarkers.subscribe(this.updateBinded));
|
|
||||||
this.unsubscribes.push(distanceUnits.subscribe(this.updateBinded));
|
|
||||||
this.map.on('style.import.load', this.updateBinded);
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
try {
|
|
||||||
if (get(distanceMarkers)) {
|
|
||||||
let distanceSource = this.map.getSource('distance-markers');
|
|
||||||
if (distanceSource) {
|
|
||||||
distanceSource.setData(this.getDistanceMarkersGeoJSON());
|
|
||||||
} else {
|
|
||||||
this.map.addSource('distance-markers', {
|
|
||||||
type: 'geojson',
|
|
||||||
data: this.getDistanceMarkersGeoJSON()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
stops.forEach(([d, minzoom, maxzoom]) => {
|
|
||||||
if (!this.map.getLayer(`distance-markers-${d}`)) {
|
|
||||||
this.map.addLayer({
|
|
||||||
id: `distance-markers-${d}`,
|
|
||||||
type: 'symbol',
|
|
||||||
source: 'distance-markers',
|
|
||||||
filter: d === 5 ? ['any', ['==', ['get', 'level'], 5], ['==', ['get', 'level'], 25]] : ['==', ['get', 'level'], d],
|
|
||||||
minzoom: minzoom,
|
|
||||||
maxzoom: maxzoom ?? 24,
|
|
||||||
layout: {
|
|
||||||
'text-field': ['get', 'distance'],
|
|
||||||
'text-size': 14,
|
|
||||||
'text-font': ['Open Sans Bold'],
|
|
||||||
},
|
|
||||||
paint: {
|
|
||||||
'text-color': 'black',
|
|
||||||
'text-halo-width': 2,
|
|
||||||
'text-halo-color': 'white',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.map.moveLayer(`distance-markers-${d}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stops.forEach(([d]) => {
|
|
||||||
if (this.map.getLayer(`distance-markers-${d}`)) {
|
|
||||||
this.map.removeLayer(`distance-markers-${d}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) { // No reliable way to check if the map is ready to add sources and layers
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
remove() {
|
|
||||||
this.unsubscribes.forEach(unsubscribe => unsubscribe());
|
|
||||||
}
|
|
||||||
|
|
||||||
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
|
|
||||||
let statistics = get(gpxStatistics);
|
|
||||||
|
|
||||||
let features = [];
|
|
||||||
let currentTargetDistance = 1;
|
|
||||||
for (let i = 0; i < statistics.local.distance.total.length; i++) {
|
|
||||||
if (statistics.local.distance.total[i] >= currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)) {
|
|
||||||
let distance = currentTargetDistance.toFixed(0);
|
|
||||||
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [0, 0];
|
|
||||||
features.push({
|
|
||||||
type: 'Feature',
|
|
||||||
geometry: {
|
|
||||||
type: 'Point',
|
|
||||||
coordinates: [statistics.local.points[i].getLongitude(), statistics.local.points[i].getLatitude()]
|
|
||||||
},
|
|
||||||
properties: {
|
|
||||||
distance,
|
|
||||||
level,
|
|
||||||
minzoom,
|
|
||||||
}
|
|
||||||
} as GeoJSON.Feature);
|
|
||||||
currentTargetDistance += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'FeatureCollection',
|
|
||||||
features
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,477 +0,0 @@
|
|||||||
import { currentTool, map, Tool } from "$lib/stores";
|
|
||||||
import { settings, type GPXFileWithStatistics, dbUtils } from "$lib/db";
|
|
||||||
import { get, type Readable } from "svelte/store";
|
|
||||||
import mapboxgl from "mapbox-gl";
|
|
||||||
import { waypointPopup, deleteWaypoint, trackpointPopup } from "./GPXLayerPopup";
|
|
||||||
import { addSelectItem, selectItem, selection } from "$lib/components/file-list/Selection";
|
|
||||||
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
|
|
||||||
import { getClosestLinePoint, getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
|
|
||||||
import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
|
|
||||||
import { MapPin, Square } from "lucide-static";
|
|
||||||
import { getSymbolKey, symbols } from "$lib/assets/symbols";
|
|
||||||
|
|
||||||
const colors = [
|
|
||||||
'#ff0000',
|
|
||||||
'#0000ff',
|
|
||||||
'#46e646',
|
|
||||||
'#00ccff',
|
|
||||||
'#ff9900',
|
|
||||||
'#ff00ff',
|
|
||||||
'#ffff32',
|
|
||||||
'#288228',
|
|
||||||
'#9933ff',
|
|
||||||
'#50f0be',
|
|
||||||
'#8c645a'
|
|
||||||
];
|
|
||||||
|
|
||||||
const colorCount: { [key: string]: number } = {};
|
|
||||||
for (let color of colors) {
|
|
||||||
colorCount[color] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the color with the least amount of uses
|
|
||||||
function getColor() {
|
|
||||||
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
|
|
||||||
colorCount[color]++;
|
|
||||||
return color;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decrementColor(color: string) {
|
|
||||||
if (colorCount.hasOwnProperty(color)) {
|
|
||||||
colorCount[color]--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const inspectKey = 'Shift';
|
|
||||||
let inspectKeyDown: KeyDown | null = null;
|
|
||||||
class KeyDown {
|
|
||||||
key: string;
|
|
||||||
down: boolean = false;
|
|
||||||
constructor(key: string) {
|
|
||||||
this.key = key;
|
|
||||||
document.addEventListener('keydown', this.onKeyDown);
|
|
||||||
document.addEventListener('keyup', this.onKeyUp);
|
|
||||||
}
|
|
||||||
onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === this.key) {
|
|
||||||
this.down = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onKeyUp = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === this.key) {
|
|
||||||
this.down = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isDown() {
|
|
||||||
return this.down;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
|
|
||||||
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
${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"', '')
|
|
||||||
.replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`)
|
|
||||||
.replace('circle', `circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`)}
|
|
||||||
${symbolSvg?.replace('width="24"', 'width="10"')
|
|
||||||
.replace('height="24"', 'height="10"')
|
|
||||||
.replace('stroke="currentColor"', 'stroke="white"')
|
|
||||||
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''}
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { directionMarkers, verticalFileView, defaultOpacity, defaultWeight } = settings;
|
|
||||||
|
|
||||||
export class GPXLayer {
|
|
||||||
map: mapboxgl.Map;
|
|
||||||
fileId: string;
|
|
||||||
file: Readable<GPXFileWithStatistics | undefined>;
|
|
||||||
layerColor: string;
|
|
||||||
markers: mapboxgl.Marker[] = [];
|
|
||||||
selected: boolean = false;
|
|
||||||
draggable: boolean;
|
|
||||||
unsubscribe: Function[] = [];
|
|
||||||
|
|
||||||
updateBinded: () => void = this.update.bind(this);
|
|
||||||
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
|
|
||||||
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
|
|
||||||
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
|
|
||||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
|
||||||
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
|
|
||||||
|
|
||||||
constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
|
|
||||||
this.map = map;
|
|
||||||
this.fileId = fileId;
|
|
||||||
this.file = file;
|
|
||||||
this.layerColor = getColor();
|
|
||||||
this.unsubscribe.push(file.subscribe(this.updateBinded));
|
|
||||||
this.unsubscribe.push(selection.subscribe($selection => {
|
|
||||||
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
|
|
||||||
if (this.selected || newSelected) {
|
|
||||||
this.selected = newSelected;
|
|
||||||
this.update();
|
|
||||||
}
|
|
||||||
if (newSelected) {
|
|
||||||
this.moveToFront();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
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;
|
|
||||||
|
|
||||||
this.map.on('style.import.load', this.updateBinded);
|
|
||||||
|
|
||||||
if (inspectKeyDown === null) {
|
|
||||||
inspectKeyDown = new KeyDown(inspectKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
let file = get(this.file)?.file;
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file._data.style && file._data.style.color && this.layerColor !== `#${file._data.style.color}`) {
|
|
||||||
decrementColor(this.layerColor);
|
|
||||||
this.layerColor = `#${file._data.style.color}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let source = this.map.getSource(this.fileId);
|
|
||||||
if (source) {
|
|
||||||
source.setData(this.getGeoJSON());
|
|
||||||
} else {
|
|
||||||
this.map.addSource(this.fileId, {
|
|
||||||
type: 'geojson',
|
|
||||||
data: this.getGeoJSON()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.map.getLayer(this.fileId)) {
|
|
||||||
this.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', 'weight'],
|
|
||||||
'line-opacity': ['get', 'opacity']
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.map.on('click', this.fileId, this.layerOnClickBinded);
|
|
||||||
this.map.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
|
||||||
this.map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
|
||||||
this.map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
|
||||||
this.map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (get(directionMarkers)) {
|
|
||||||
if (!this.map.getLayer(this.fileId + '-direction')) {
|
|
||||||
this.map.addLayer({
|
|
||||||
id: this.fileId + '-direction',
|
|
||||||
type: 'symbol',
|
|
||||||
source: this.fileId,
|
|
||||||
layout: {
|
|
||||||
'text-field': '»',
|
|
||||||
'text-offset': [0, -0.1],
|
|
||||||
'text-keep-upright': false,
|
|
||||||
'text-max-angle': 361,
|
|
||||||
'text-allow-overlap': true,
|
|
||||||
'text-font': ['Open Sans Bold'],
|
|
||||||
'symbol-placement': 'line',
|
|
||||||
'symbol-spacing': 20,
|
|
||||||
},
|
|
||||||
paint: {
|
|
||||||
'text-color': 'white',
|
|
||||||
'text-opacity': 0.7,
|
|
||||||
'text-halo-width': 0.2,
|
|
||||||
'text-halo-color': 'white'
|
|
||||||
}
|
|
||||||
}, this.map.getLayer('distance-markers') ? 'distance-markers' : undefined);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
|
||||||
this.map.removeLayer(this.fileId + '-direction');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let visibleItems: [number, number][] = [];
|
|
||||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
|
||||||
if (!segment._data.hidden) {
|
|
||||||
visibleItems.push([trackIndex, segmentIndex]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.map.setFilter(this.fileId, ['any', ...visibleItems.map(([trackIndex, segmentIndex]) => ['all', ['==', 'trackIndex', trackIndex], ['==', 'segmentIndex', segmentIndex]])], { validate: false });
|
|
||||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
|
||||||
this.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) {
|
|
||||||
deleteWaypoint(this.fileId, marker._waypoint._data.index);
|
|
||||||
e.stopPropagation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (get(verticalFileView)) {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), false)) {
|
|
||||||
addSelectItem(new ListWaypointItem(this.fileId, marker._waypoint._data.index));
|
|
||||||
} else {
|
|
||||||
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', () => {
|
|
||||||
setGrabbingCursor();
|
|
||||||
marker.getElement().style.cursor = 'grabbing';
|
|
||||||
waypointPopup?.hide();
|
|
||||||
});
|
|
||||||
marker.on('dragend', (e) => {
|
|
||||||
resetCursor();
|
|
||||||
marker.getElement().style.cursor = '';
|
|
||||||
getElevation([marker._waypoint]).then((ele) => {
|
|
||||||
dbUtils.applyToFile(this.fileId, (file) => {
|
|
||||||
let latLng = marker.getLngLat();
|
|
||||||
let wpt = file.wpt[marker._waypoint._data.index];
|
|
||||||
wpt.setCoordinates({
|
|
||||||
lat: latLng.lat,
|
|
||||||
lon: latLng.lng
|
|
||||||
});
|
|
||||||
wpt.ele = ele[0];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
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(this.map);
|
|
||||||
} else {
|
|
||||||
marker.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMap(map: mapboxgl.Map) {
|
|
||||||
this.map = map;
|
|
||||||
this.map.on('style.import.load', this.updateBinded);
|
|
||||||
this.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
remove() {
|
|
||||||
if (get(map)) {
|
|
||||||
this.map.off('click', this.fileId, this.layerOnClickBinded);
|
|
||||||
this.map.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
|
||||||
this.map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
|
||||||
this.map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
|
||||||
this.map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
|
||||||
this.map.off('style.import.load', this.updateBinded);
|
|
||||||
|
|
||||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
|
||||||
this.map.removeLayer(this.fileId + '-direction');
|
|
||||||
}
|
|
||||||
if (this.map.getLayer(this.fileId)) {
|
|
||||||
this.map.removeLayer(this.fileId);
|
|
||||||
}
|
|
||||||
if (this.map.getSource(this.fileId)) {
|
|
||||||
this.map.removeSource(this.fileId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.markers.forEach((marker) => {
|
|
||||||
marker.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.unsubscribe.forEach((unsubscribe) => unsubscribe());
|
|
||||||
|
|
||||||
decrementColor(this.layerColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
moveToFront() {
|
|
||||||
if (this.map.getLayer(this.fileId)) {
|
|
||||||
this.map.moveLayer(this.fileId);
|
|
||||||
}
|
|
||||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
|
||||||
this.map.moveLayer(this.fileId + '-direction', this.map.getLayer('distance-markers') ? 'distance-markers' : undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
layerOnMouseEnter(e: any) {
|
|
||||||
let trackIndex = e.features[0].properties.trackIndex;
|
|
||||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
|
||||||
|
|
||||||
if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
|
||||||
setScissorsCursor();
|
|
||||||
} else {
|
|
||||||
setPointerCursor();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
layerOnMouseLeave() {
|
|
||||||
resetCursor();
|
|
||||||
}
|
|
||||||
|
|
||||||
layerOnMouseMove(e: any) {
|
|
||||||
if (inspectKeyDown?.isDown()) {
|
|
||||||
let trackIndex = e.features[0].properties.trackIndex;
|
|
||||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
|
||||||
|
|
||||||
const file = get(this.file)?.file;
|
|
||||||
if (file) {
|
|
||||||
const closest = getClosestLinePoint(file.trk[trackIndex].trkseg[segmentIndex].trkpt, { lat: e.lngLat.lat, lon: e.lngLat.lng });
|
|
||||||
trackpointPopup?.setItem({ item: closest, fileId: this.fileId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
layerOnClick(e: any) {
|
|
||||||
if (get(currentTool) === Tool.ROUTING && get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let trackIndex = e.features[0].properties.trackIndex;
|
|
||||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
|
||||||
|
|
||||||
if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
|
||||||
dbUtils.split(this.fileId, trackIndex, segmentIndex, { lat: e.lngLat.lat, lon: e.lngLat.lng });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let file = get(this.file)?.file;
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let item = undefined;
|
|
||||||
if (get(verticalFileView) && file.getSegments().length > 1) { // Select inner item
|
|
||||||
item = file.children[trackIndex].children.length > 1 ? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) : new ListTrackItem(this.fileId, trackIndex);
|
|
||||||
} else {
|
|
||||||
item = new ListFileItem(this.fileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
|
|
||||||
addSelectItem(item);
|
|
||||||
} else {
|
|
||||||
selectItem(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
layerOnContextMenu(e: any) {
|
|
||||||
if (e.originalEvent.ctrlKey) {
|
|
||||||
this.layerOnClick(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getGeoJSON(): GeoJSON.FeatureCollection {
|
|
||||||
let file = get(this.file)?.file;
|
|
||||||
if (!file) {
|
|
||||||
return {
|
|
||||||
type: 'FeatureCollection',
|
|
||||||
features: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = file.toGeoJSON();
|
|
||||||
|
|
||||||
let trackIndex = 0, segmentIndex = 0;
|
|
||||||
for (let feature of data.features) {
|
|
||||||
if (!feature.properties) {
|
|
||||||
feature.properties = {};
|
|
||||||
}
|
|
||||||
if (!feature.properties.color) {
|
|
||||||
feature.properties.color = this.layerColor;
|
|
||||||
}
|
|
||||||
if (!feature.properties.weight) {
|
|
||||||
feature.properties.weight = get(defaultWeight);
|
|
||||||
}
|
|
||||||
if (!feature.properties.opacity) {
|
|
||||||
feature.properties.opacity = get(defaultOpacity);
|
|
||||||
}
|
|
||||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)) || get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)) {
|
|
||||||
feature.properties.weight = feature.properties.weight + 2;
|
|
||||||
feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1);
|
|
||||||
}
|
|
||||||
feature.properties.trackIndex = trackIndex;
|
|
||||||
feature.properties.segmentIndex = segmentIndex;
|
|
||||||
|
|
||||||
segmentIndex++;
|
|
||||||
if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
|
|
||||||
segmentIndex = 0;
|
|
||||||
trackIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { map, gpxLayers } from '$lib/stores';
|
|
||||||
import { GPXLayer } from './GPXLayer';
|
|
||||||
import { fileObservers } from '$lib/db';
|
|
||||||
import { DistanceMarkers } from './DistanceMarkers';
|
|
||||||
import { StartEndMarkers } from './StartEndMarkers';
|
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import { createPopups, removePopups } from './GPXLayerPopup';
|
|
||||||
|
|
||||||
let distanceMarkers: DistanceMarkers | undefined = undefined;
|
|
||||||
let startEndMarkers: StartEndMarkers | undefined = undefined;
|
|
||||||
|
|
||||||
$: if ($map && $fileObservers) {
|
|
||||||
// remove layers for deleted files
|
|
||||||
gpxLayers.forEach((layer, fileId) => {
|
|
||||||
if (!$fileObservers.has(fileId)) {
|
|
||||||
layer.remove();
|
|
||||||
gpxLayers.delete(fileId);
|
|
||||||
} else if ($map !== layer.map) {
|
|
||||||
layer.updateMap($map);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// add layers for new files
|
|
||||||
$fileObservers.forEach((file, fileId) => {
|
|
||||||
if (!gpxLayers.has(fileId)) {
|
|
||||||
gpxLayers.set(fileId, new GPXLayer($map, fileId, file));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($map) {
|
|
||||||
if (distanceMarkers) {
|
|
||||||
distanceMarkers.remove();
|
|
||||||
}
|
|
||||||
if (startEndMarkers) {
|
|
||||||
startEndMarkers.remove();
|
|
||||||
}
|
|
||||||
createPopups($map);
|
|
||||||
distanceMarkers = new DistanceMarkers($map);
|
|
||||||
startEndMarkers = new StartEndMarkers($map);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
gpxLayers.forEach((layer) => layer.remove());
|
|
||||||
gpxLayers.clear();
|
|
||||||
removePopups();
|
|
||||||
if (distanceMarkers) {
|
|
||||||
distanceMarkers.remove();
|
|
||||||
distanceMarkers = undefined;
|
|
||||||
}
|
|
||||||
if (startEndMarkers) {
|
|
||||||
startEndMarkers.remove();
|
|
||||||
startEndMarkers = undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { TrackPoint } from 'gpx';
|
|
||||||
import type { PopupItem } from '$lib/components/MapPopup';
|
|
||||||
import * as Card from '$lib/components/ui/card';
|
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
|
||||||
import { Compass, Mountain, Timer } from 'lucide-svelte';
|
|
||||||
import { df } from '$lib/utils';
|
|
||||||
|
|
||||||
export let trackpoint: PopupItem<TrackPoint>;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Card.Root class="border-none shadow-md text-base p-2">
|
|
||||||
<Card.Header class="p-0">
|
|
||||||
<Card.Title class="text-md"></Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="flex flex-col p-0 text-xs gap-1">
|
|
||||||
<div class="flex flex-row items-center gap-1">
|
|
||||||
<Compass size="14" />
|
|
||||||
{trackpoint.item.getLatitude().toFixed(6)}° {trackpoint.item
|
|
||||||
.getLongitude()
|
|
||||||
.toFixed(6)}°
|
|
||||||
</div>
|
|
||||||
{#if trackpoint.item.ele !== undefined}
|
|
||||||
<div class="flex flex-row items-center gap-1">
|
|
||||||
<Mountain size="14" />
|
|
||||||
<WithUnits value={trackpoint.item.ele} type="elevation" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if trackpoint.item.time}
|
|
||||||
<div class="flex flex-row items-center gap-1">
|
|
||||||
<Timer size="14" />
|
|
||||||
{df.format(trackpoint.item.time)}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import * as Card from '$lib/components/ui/card';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
|
||||||
import { deleteWaypoint } from './GPXLayerPopup';
|
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
|
||||||
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
|
|
||||||
import { Tool, currentTool } from '$lib/stores';
|
|
||||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
import sanitizeHtml from 'sanitize-html';
|
|
||||||
import type { Waypoint } from 'gpx';
|
|
||||||
import type { PopupItem } from '$lib/components/MapPopup';
|
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
|
||||||
|
|
||||||
export let waypoint: PopupItem<Waypoint>;
|
|
||||||
|
|
||||||
$: symbolKey = waypoint ? getSymbolKey(waypoint.item.sym) : undefined;
|
|
||||||
|
|
||||||
function sanitize(text: string | undefined): string {
|
|
||||||
if (text === undefined) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return sanitizeHtml(text, {
|
|
||||||
allowedTags: ['a', 'br', 'img'],
|
|
||||||
allowedAttributes: {
|
|
||||||
a: ['href', 'target'],
|
|
||||||
img: ['src']
|
|
||||||
}
|
|
||||||
}).trim();
|
|
||||||
}
|
|
||||||
</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">
|
|
||||||
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href}
|
|
||||||
<a href={waypoint.item.link.attributes.href} target="_blank">
|
|
||||||
{waypoint.item.name ?? waypoint.item.link.attributes.href}
|
|
||||||
<ExternalLink size="12" class="inline-block mb-1.5" />
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
{waypoint.item.name ?? $_('gpx.waypoint')}
|
|
||||||
{/if}
|
|
||||||
</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="flex flex-col text-sm p-0">
|
|
||||||
<div class="flex flex-row items-center text-muted-foreground text-xs whitespace-nowrap">
|
|
||||||
{#if symbolKey}
|
|
||||||
<span>
|
|
||||||
{#if symbols[symbolKey].icon}
|
|
||||||
<svelte:component
|
|
||||||
this={symbols[symbolKey].icon}
|
|
||||||
size="12"
|
|
||||||
class="inline-block mb-0.5"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<span class="w-4 inline-block" />
|
|
||||||
{/if}
|
|
||||||
{$_(`gpx.symbol.${symbolKey}`)}
|
|
||||||
</span>
|
|
||||||
<Dot size="16" />
|
|
||||||
{/if}
|
|
||||||
{waypoint.item.getLatitude().toFixed(6)}° {waypoint.item.getLongitude().toFixed(6)}°
|
|
||||||
{#if waypoint.item.ele !== undefined}
|
|
||||||
<Dot size="16" />
|
|
||||||
<WithUnits value={waypoint.item.ele} type="elevation" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
|
|
||||||
{#if waypoint.item.desc}
|
|
||||||
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if waypoint.item.cmt && waypoint.item.cmt !== waypoint.item.desc}
|
|
||||||
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.cmt)}</span>
|
|
||||||
{/if}
|
|
||||||
</ScrollArea>
|
|
||||||
{#if $currentTool === Tool.WAYPOINT}
|
|
||||||
<Button
|
|
||||||
class="mt-2 w-full px-2 py-1 h-8 justify-start"
|
|
||||||
variant="outline"
|
|
||||||
on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
|
|
||||||
>
|
|
||||||
<Trash2 size="16" class="mr-1" />
|
|
||||||
{$_('menu.delete')}
|
|
||||||
<Shortcut shift={true} click={true} />
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
|
|
||||||
<style lang="postcss">
|
|
||||||
div :global(a) {
|
|
||||||
@apply text-link;
|
|
||||||
@apply hover:underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(img) {
|
|
||||||
@apply my-0;
|
|
||||||
@apply rounded-md;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import * as Card from '$lib/components/ui/card';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
|
||||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
|
||||||
import {
|
|
||||||
CirclePlus,
|
|
||||||
CircleX,
|
|
||||||
Minus,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Save,
|
|
||||||
Trash2,
|
|
||||||
Move,
|
|
||||||
Map,
|
|
||||||
Layers2
|
|
||||||
} from 'lucide-svelte';
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
import { settings } from '$lib/db';
|
|
||||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
import Sortable from 'sortablejs/Sortable';
|
|
||||||
import { customBasemapUpdate } from './utils';
|
|
||||||
|
|
||||||
const {
|
|
||||||
customLayers,
|
|
||||||
selectedBasemapTree,
|
|
||||||
selectedOverlayTree,
|
|
||||||
currentBasemap,
|
|
||||||
previousBasemap,
|
|
||||||
currentOverlays,
|
|
||||||
previousOverlays,
|
|
||||||
customBasemapOrder,
|
|
||||||
customOverlayOrder
|
|
||||||
} = settings;
|
|
||||||
|
|
||||||
let name: string = '';
|
|
||||||
let tileUrls: string[] = [''];
|
|
||||||
let maxZoom: number = 20;
|
|
||||||
let layerType: 'basemap' | 'overlay' = 'basemap';
|
|
||||||
let resourceType: 'raster' | 'vector' = 'raster';
|
|
||||||
|
|
||||||
let basemapContainer: HTMLElement;
|
|
||||||
let overlayContainer: HTMLElement;
|
|
||||||
|
|
||||||
let basemapSortable: Sortable;
|
|
||||||
let overlaySortable: Sortable;
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if ($customBasemapOrder.length === 0) {
|
|
||||||
$customBasemapOrder = Object.keys($customLayers).filter(
|
|
||||||
(id) => $customLayers[id].layerType === 'basemap'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ($customOverlayOrder.length === 0) {
|
|
||||||
$customOverlayOrder = Object.keys($customLayers).filter(
|
|
||||||
(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();
|
|
||||||
});
|
|
||||||
|
|
||||||
$: if (tileUrls[0].length > 0) {
|
|
||||||
if (
|
|
||||||
tileUrls[0].includes('.json') ||
|
|
||||||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
|
|
||||||
) {
|
|
||||||
resourceType = 'vector';
|
|
||||||
} else {
|
|
||||||
resourceType = 'raster';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLayer() {
|
|
||||||
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
|
|
||||||
deleteLayer(selectedLayerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof maxZoom === 'string') {
|
|
||||||
maxZoom = parseInt(maxZoom);
|
|
||||||
}
|
|
||||||
let is512 = tileUrls.some((url) => url.includes('512'));
|
|
||||||
|
|
||||||
let layerId = selectedLayerId ?? getLayerId();
|
|
||||||
let layer: CustomLayer = {
|
|
||||||
id: layerId,
|
|
||||||
name: name,
|
|
||||||
tileUrls: tileUrls.map((url) => decodeURI(url.trim())),
|
|
||||||
maxZoom: maxZoom,
|
|
||||||
layerType: layerType,
|
|
||||||
resourceType: resourceType,
|
|
||||||
value: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
if (resourceType === 'vector') {
|
|
||||||
layer.value = layer.tileUrls[0];
|
|
||||||
} else {
|
|
||||||
layer.value = {
|
|
||||||
version: 8,
|
|
||||||
sources: {
|
|
||||||
[layerId]: {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: layer.tileUrls,
|
|
||||||
tileSize: is512 ? 512 : 256,
|
|
||||||
maxzoom: maxZoom
|
|
||||||
}
|
|
||||||
},
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: layerId,
|
|
||||||
type: 'raster',
|
|
||||||
source: layerId
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
$customLayers[layerId] = layer;
|
|
||||||
addLayer(layerId);
|
|
||||||
selectedLayerId = undefined;
|
|
||||||
setDataFromSelectedLayer();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLayerId() {
|
|
||||||
for (let id = 0; ; id++) {
|
|
||||||
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
|
|
||||||
return `custom-${id}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLayer(layerId: string) {
|
|
||||||
if (layerType === 'basemap') {
|
|
||||||
selectedBasemapTree.update(($tree) => {
|
|
||||||
if (!$tree.basemaps.hasOwnProperty('custom')) {
|
|
||||||
$tree.basemaps['custom'] = {};
|
|
||||||
}
|
|
||||||
$tree.basemaps['custom'][layerId] = true;
|
|
||||||
return $tree;
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($currentBasemap === layerId) {
|
|
||||||
$customBasemapUpdate++;
|
|
||||||
} else {
|
|
||||||
$currentBasemap = layerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$customBasemapOrder.includes(layerId)) {
|
|
||||||
$customBasemapOrder = [...$customBasemapOrder, layerId];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
selectedOverlayTree.update(($tree) => {
|
|
||||||
if (!$tree.overlays.hasOwnProperty('custom')) {
|
|
||||||
$tree.overlays['custom'] = {};
|
|
||||||
}
|
|
||||||
$tree.overlays['custom'][layerId] = true;
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
|
|
||||||
$currentOverlays.overlays['custom'] = {};
|
|
||||||
}
|
|
||||||
$currentOverlays.overlays['custom'][layerId] = true;
|
|
||||||
|
|
||||||
if (!$customOverlayOrder.includes(layerId)) {
|
|
||||||
$customOverlayOrder = [...$customOverlayOrder, layerId];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryDeleteLayer(node: any, id: string): any {
|
|
||||||
if (node.hasOwnProperty(id)) {
|
|
||||||
delete node[id];
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteLayer(layerId: string) {
|
|
||||||
let layer = $customLayers[layerId];
|
|
||||||
if (layer.layerType === 'basemap') {
|
|
||||||
if (layerId === $currentBasemap) {
|
|
||||||
$currentBasemap = defaultBasemap;
|
|
||||||
}
|
|
||||||
if (layerId === $previousBasemap) {
|
|
||||||
$previousBasemap = defaultBasemap;
|
|
||||||
}
|
|
||||||
|
|
||||||
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
|
|
||||||
$selectedBasemapTree.basemaps['custom'],
|
|
||||||
layerId
|
|
||||||
);
|
|
||||||
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
|
|
||||||
$selectedBasemapTree.basemaps = tryDeleteLayer($selectedBasemapTree.basemaps, 'custom');
|
|
||||||
}
|
|
||||||
$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');
|
|
||||||
}
|
|
||||||
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
|
|
||||||
|
|
||||||
if (
|
|
||||||
$currentOverlays.overlays['custom'] &&
|
|
||||||
$currentOverlays.overlays['custom'][layerId] &&
|
|
||||||
$map
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
$map.removeImport(layerId);
|
|
||||||
} catch (e) {
|
|
||||||
// No reliable way to check if the map is ready to remove sources and layers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$customLayers = tryDeleteLayer($customLayers, layerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectedLayerId: string | undefined = undefined;
|
|
||||||
|
|
||||||
function setDataFromSelectedLayer() {
|
|
||||||
if (selectedLayerId) {
|
|
||||||
const layer = $customLayers[selectedLayerId];
|
|
||||||
name = layer.name;
|
|
||||||
tileUrls = layer.tileUrls;
|
|
||||||
maxZoom = layer.maxZoom;
|
|
||||||
layerType = layer.layerType;
|
|
||||||
resourceType = layer.resourceType;
|
|
||||||
} else {
|
|
||||||
name = '';
|
|
||||||
tileUrls = [''];
|
|
||||||
maxZoom = 20;
|
|
||||||
layerType = 'basemap';
|
|
||||||
resourceType = 'raster';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: selectedLayerId, setDataFromSelectedLayer();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col">
|
|
||||||
{#if $customBasemapOrder.length > 0}
|
|
||||||
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
|
||||||
<Map size="16" />
|
|
||||||
{$_('layers.label.basemaps')}
|
|
||||||
<div class="grow">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div
|
|
||||||
bind:this={basemapContainer}
|
|
||||||
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
|
|
||||||
>
|
|
||||||
{#each $customBasemapOrder as id (id)}
|
|
||||||
<div class="flex flex-row items-center gap-2" data-id={id}>
|
|
||||||
<Move size="12" />
|
|
||||||
<span class="grow">{$customLayers[id].name}</span>
|
|
||||||
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
|
|
||||||
<Pencil size="16" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
|
|
||||||
<Trash2 size="16" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if $customOverlayOrder.length > 0}
|
|
||||||
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
|
||||||
<Layers2 size="16" />
|
|
||||||
{$_('layers.label.overlays')}
|
|
||||||
<div class="grow">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div
|
|
||||||
bind:this={overlayContainer}
|
|
||||||
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
|
|
||||||
>
|
|
||||||
{#each $customOverlayOrder as id (id)}
|
|
||||||
<div class="flex flex-row items-center gap-2" data-id={id}>
|
|
||||||
<Move size="12" />
|
|
||||||
<span class="grow">{$customLayers[id].name}</span>
|
|
||||||
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
|
|
||||||
<Pencil size="16" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
|
|
||||||
<Trash2 size="16" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card.Root>
|
|
||||||
<Card.Header class="p-3">
|
|
||||||
<Card.Title class="text-base">
|
|
||||||
{#if selectedLayerId}
|
|
||||||
{$_('layers.custom_layers.edit')}
|
|
||||||
{:else}
|
|
||||||
{$_('layers.custom_layers.new')}
|
|
||||||
{/if}
|
|
||||||
</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="p-3 pt-0">
|
|
||||||
<fieldset class="flex flex-col gap-2">
|
|
||||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
|
||||||
<Input bind:value={name} id="name" class="h-8" />
|
|
||||||
<Label for="url">{$_('layers.custom_layers.urls')}</Label>
|
|
||||||
{#each tileUrls as url, i}
|
|
||||||
<div class="flex flex-row gap-2">
|
|
||||||
<Input
|
|
||||||
bind:value={tileUrls[i]}
|
|
||||||
id="url"
|
|
||||||
class="h-8"
|
|
||||||
placeholder={$_('layers.custom_layers.url_placeholder')}
|
|
||||||
/>
|
|
||||||
{#if tileUrls.length > 1}
|
|
||||||
<Button
|
|
||||||
on:click={() => (tileUrls = tileUrls.filter((_, index) => index !== i))}
|
|
||||||
variant="outline"
|
|
||||||
class="p-1 h-8"
|
|
||||||
>
|
|
||||||
<Minus size="16" />
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
{#if i === tileUrls.length - 1}
|
|
||||||
<Button
|
|
||||||
on:click={() => (tileUrls = [...tileUrls, ''])}
|
|
||||||
variant="outline"
|
|
||||||
class="p-1 h-8"
|
|
||||||
>
|
|
||||||
<Plus size="16" />
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{#if resourceType === 'raster'}
|
|
||||||
<Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
|
|
||||||
<Input type="number" bind:value={maxZoom} id="maxZoom" min={0} max={22} class="h-8" />
|
|
||||||
{/if}
|
|
||||||
<Label>{$_('layers.custom_layers.layer_type')}</Label>
|
|
||||||
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<RadioGroup.Item value="basemap" id="basemap" />
|
|
||||||
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<RadioGroup.Item value="overlay" id="overlay" />
|
|
||||||
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup.Root>
|
|
||||||
{#if selectedLayerId}
|
|
||||||
<div class="mt-2 flex flex-row gap-2">
|
|
||||||
<Button variant="outline" on:click={createLayer} class="grow">
|
|
||||||
<Save size="16" class="mr-1" />
|
|
||||||
{$_('layers.custom_layers.update')}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
|
|
||||||
<CircleX size="16" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<Button variant="outline" class="mt-2" on:click={createLayer}>
|
|
||||||
<CirclePlus size="16" class="mr-1" />
|
|
||||||
{$_('layers.custom_layers.create')}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</fieldset>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
</div>
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
|
||||||
import LayerTree from './LayerTree.svelte';
|
|
||||||
|
|
||||||
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/db';
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
import { get, writable } from 'svelte/store';
|
|
||||||
import { customBasemapUpdate, getLayers } from './utils';
|
|
||||||
import { OverpassLayer } from './OverpassLayer';
|
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
|
||||||
let overpassLayer: OverpassLayer;
|
|
||||||
|
|
||||||
const {
|
|
||||||
currentBasemap,
|
|
||||||
previousBasemap,
|
|
||||||
currentOverlays,
|
|
||||||
currentOverpassQueries,
|
|
||||||
selectedBasemapTree,
|
|
||||||
selectedOverlayTree,
|
|
||||||
selectedOverpassTree,
|
|
||||||
customLayers,
|
|
||||||
opacities
|
|
||||||
} = settings;
|
|
||||||
|
|
||||||
function setStyle() {
|
|
||||||
if ($map) {
|
|
||||||
let basemap = basemaps.hasOwnProperty($currentBasemap)
|
|
||||||
? basemaps[$currentBasemap]
|
|
||||||
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
|
|
||||||
$map.removeImport('basemap');
|
|
||||||
if (typeof basemap === 'string') {
|
|
||||||
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
|
|
||||||
} else {
|
|
||||||
$map.addImport(
|
|
||||||
{
|
|
||||||
id: 'basemap',
|
|
||||||
data: basemap
|
|
||||||
},
|
|
||||||
'overlays'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($map && ($currentBasemap || $customBasemapUpdate)) {
|
|
||||||
setStyle();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addOverlay(id: string) {
|
|
||||||
try {
|
|
||||||
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
|
|
||||||
if (typeof overlay === 'string') {
|
|
||||||
$map.addImport({ id, url: overlay });
|
|
||||||
} else {
|
|
||||||
if ($opacities.hasOwnProperty(id)) {
|
|
||||||
overlay = {
|
|
||||||
...overlay,
|
|
||||||
layers: overlay.layers.map((layer) => {
|
|
||||||
if (layer.type === 'raster') {
|
|
||||||
if (!layer.paint) {
|
|
||||||
layer.paint = {};
|
|
||||||
}
|
|
||||||
layer.paint['raster-opacity'] = $opacities[id];
|
|
||||||
}
|
|
||||||
return layer;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
$map.addImport({
|
|
||||||
id,
|
|
||||||
data: overlay
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// No reliable way to check if the map is ready to add sources and layers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateOverlays() {
|
|
||||||
if ($map && $currentOverlays && $opacities) {
|
|
||||||
let overlayLayers = getLayers($currentOverlays);
|
|
||||||
try {
|
|
||||||
let activeOverlays = $map.getStyle().imports.reduce((acc, i) => {
|
|
||||||
if (!['basemap', 'overlays', 'glyphs-and-sprite'].includes(i.id)) {
|
|
||||||
acc[i.id] = i;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
|
|
||||||
toRemove.forEach((id) => {
|
|
||||||
$map.removeImport(id);
|
|
||||||
});
|
|
||||||
let toAdd = Object.entries(overlayLayers)
|
|
||||||
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
|
|
||||||
.map(([id]) => id);
|
|
||||||
toAdd.forEach((id) => {
|
|
||||||
addOverlay(id);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// No reliable way to check if the map is ready to add sources and layers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($map && $currentOverlays && $opacities) {
|
|
||||||
updateOverlays();
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($map) {
|
|
||||||
if (overpassLayer) {
|
|
||||||
overpassLayer.remove();
|
|
||||||
}
|
|
||||||
overpassLayer = new OverpassLayer($map);
|
|
||||||
overpassLayer.add();
|
|
||||||
$map.on('style.import.load', updateOverlays);
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectedBasemap = writable(get(currentBasemap));
|
|
||||||
selectedBasemap.subscribe((value) => {
|
|
||||||
// Updates coming from radio buttons
|
|
||||||
if (value !== get(currentBasemap)) {
|
|
||||||
previousBasemap.set(get(currentBasemap));
|
|
||||||
currentBasemap.set(value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
currentBasemap.subscribe((value) => {
|
|
||||||
// Updates coming from the database, or from the user swapping basemaps
|
|
||||||
if (value !== get(selectedBasemap)) {
|
|
||||||
selectedBasemap.set(value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let open = false;
|
|
||||||
function openLayerControl() {
|
|
||||||
open = true;
|
|
||||||
}
|
|
||||||
function closeLayerControl() {
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
let cancelEvents = false;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<CustomControl class="group min-w-[29px] min-h-[29px] overflow-hidden">
|
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
||||||
<div
|
|
||||||
bind:this={container}
|
|
||||||
class="h-full w-full"
|
|
||||||
on:mouseenter={openLayerControl}
|
|
||||||
on:mouseleave={closeLayerControl}
|
|
||||||
on:pointerenter={() => {
|
|
||||||
if (!open) {
|
|
||||||
cancelEvents = true;
|
|
||||||
openLayerControl();
|
|
||||||
setTimeout(() => {
|
|
||||||
cancelEvents = false;
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-row justify-center items-center delay-100 transition-[opacity] duration-0 {open
|
|
||||||
? 'opacity-0 w-0 h-0 delay-0'
|
|
||||||
: 'w-[29px] h-[29px]'}"
|
|
||||||
>
|
|
||||||
<Layers size="20" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="transition-[grid-template-rows grid-template-cols] grid grid-rows-[0fr] grid-cols-[0fr] duration-150 h-full {open
|
|
||||||
? 'grid-rows-[1fr] grid-cols-[1fr]'
|
|
||||||
: ''} {cancelEvents ? 'pointer-events-none' : ''}"
|
|
||||||
>
|
|
||||||
<ScrollArea>
|
|
||||||
<div class="h-fit">
|
|
||||||
<div class="p-2">
|
|
||||||
<LayerTree
|
|
||||||
layerTree={$selectedBasemapTree}
|
|
||||||
name="basemaps"
|
|
||||||
bind:selected={$selectedBasemap}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Separator class="w-full" />
|
|
||||||
<div class="p-2">
|
|
||||||
{#if $currentOverlays}
|
|
||||||
<LayerTree
|
|
||||||
layerTree={$selectedOverlayTree}
|
|
||||||
name="overlays"
|
|
||||||
multiple={true}
|
|
||||||
bind:checked={$currentOverlays}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<Separator class="w-full" />
|
|
||||||
<div class="p-2">
|
|
||||||
{#if $currentOverpassQueries}
|
|
||||||
<LayerTree
|
|
||||||
layerTree={$selectedOverpassTree}
|
|
||||||
name="overpass"
|
|
||||||
multiple={true}
|
|
||||||
bind:checked={$currentOverpassQueries}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CustomControl>
|
|
||||||
|
|
||||||
<svelte:window
|
|
||||||
on:click={(e) => {
|
|
||||||
if (open && !cancelEvents && !container.contains(e.target)) {
|
|
||||||
closeLayerControl();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import LayerTree from './LayerTree.svelte';
|
|
||||||
|
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
|
||||||
import * as Sheet from '$lib/components/ui/sheet';
|
|
||||||
import * as Accordion from '$lib/components/ui/accordion';
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import * as Select from '$lib/components/ui/select';
|
|
||||||
import { Slider } from '$lib/components/ui/slider';
|
|
||||||
|
|
||||||
import {
|
|
||||||
basemapTree,
|
|
||||||
defaultBasemap,
|
|
||||||
overlays,
|
|
||||||
overlayTree,
|
|
||||||
overpassTree
|
|
||||||
} from '$lib/assets/layers';
|
|
||||||
import { getLayers, isSelected, toggle } from '$lib/components/layer-control/utils';
|
|
||||||
import { settings } from '$lib/db';
|
|
||||||
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
import { writable } from 'svelte/store';
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
import CustomLayers from './CustomLayers.svelte';
|
|
||||||
|
|
||||||
const {
|
|
||||||
selectedBasemapTree,
|
|
||||||
selectedOverlayTree,
|
|
||||||
selectedOverpassTree,
|
|
||||||
currentBasemap,
|
|
||||||
currentOverlays,
|
|
||||||
customLayers,
|
|
||||||
opacities
|
|
||||||
} = settings;
|
|
||||||
|
|
||||||
export let open: boolean;
|
|
||||||
let accordionValue: string | string[] | undefined = undefined;
|
|
||||||
|
|
||||||
let selectedOverlay = writable(undefined);
|
|
||||||
let overlayOpacity = writable([1]);
|
|
||||||
|
|
||||||
function setOpacityFromSelection() {
|
|
||||||
if ($selectedOverlay) {
|
|
||||||
let overlayId = $selectedOverlay.value;
|
|
||||||
if ($opacities.hasOwnProperty(overlayId)) {
|
|
||||||
$overlayOpacity = [$opacities[overlayId]];
|
|
||||||
} else {
|
|
||||||
$overlayOpacity = [1];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$overlayOpacity = [1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($selectedBasemapTree && $currentBasemap) {
|
|
||||||
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
|
|
||||||
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
|
|
||||||
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
|
|
||||||
}
|
|
||||||
$currentBasemap = defaultBasemap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($selectedOverlayTree && $currentOverlays) {
|
|
||||||
let overlayLayers = getLayers($currentOverlays);
|
|
||||||
let toRemove = Object.entries(overlayLayers).filter(
|
|
||||||
([id, checked]) => checked && !isSelected($selectedOverlayTree, id)
|
|
||||||
);
|
|
||||||
if (toRemove.length > 0) {
|
|
||||||
currentOverlays.update((tree) => {
|
|
||||||
toRemove.forEach(([id]) => {
|
|
||||||
toggle(tree, id);
|
|
||||||
});
|
|
||||||
return tree;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($selectedOverlay) {
|
|
||||||
setOpacityFromSelection();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Sheet.Root bind:open>
|
|
||||||
<Sheet.Trigger class="hidden" />
|
|
||||||
<Sheet.Content>
|
|
||||||
<Sheet.Header class="h-full">
|
|
||||||
<Sheet.Title>{$_('layers.settings')}</Sheet.Title>
|
|
||||||
<ScrollArea class="w-[105%] min-h-full pr-4">
|
|
||||||
<Sheet.Description>
|
|
||||||
{$_('layers.settings_help')}
|
|
||||||
</Sheet.Description>
|
|
||||||
<Accordion.Root class="flex flex-col" bind:value={accordionValue}>
|
|
||||||
<Accordion.Item value="layer-selection" class="flex flex-col">
|
|
||||||
<Accordion.Trigger>{$_('layers.selection')}</Accordion.Trigger>
|
|
||||||
<Accordion.Content class="grow flex flex-col border rounded">
|
|
||||||
<div class="py-2 pl-1 pr-2">
|
|
||||||
<LayerTree
|
|
||||||
layerTree={basemapTree}
|
|
||||||
name="basemapSettings"
|
|
||||||
multiple={true}
|
|
||||||
bind:checked={$selectedBasemapTree}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div class="py-2 pl-1 pr-2">
|
|
||||||
<LayerTree
|
|
||||||
layerTree={overlayTree}
|
|
||||||
name="overlaySettings"
|
|
||||||
multiple={true}
|
|
||||||
bind:checked={$selectedOverlayTree}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div class="py-2 pl-1 pr-2">
|
|
||||||
<LayerTree
|
|
||||||
layerTree={overpassTree}
|
|
||||||
name="overpassSettings"
|
|
||||||
multiple={true}
|
|
||||||
bind:checked={$selectedOverpassTree}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
<Accordion.Item value="overlay-opacity">
|
|
||||||
<Accordion.Trigger>{$_('layers.opacity')}</Accordion.Trigger>
|
|
||||||
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
|
|
||||||
<div class="flex flex-row gap-6 items-center">
|
|
||||||
<Label>
|
|
||||||
{$_('layers.custom_layers.overlay')}
|
|
||||||
</Label>
|
|
||||||
<Select.Root bind:selected={$selectedOverlay}>
|
|
||||||
<Select.Trigger class="h-8 mr-1">
|
|
||||||
<Select.Value />
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
|
|
||||||
{#each Object.keys(overlays) as id}
|
|
||||||
{#if isSelected($selectedOverlayTree, id)}
|
|
||||||
<Select.Item value={id}>{$_(`layers.label.${id}`)}</Select.Item>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{#each Object.entries($customLayers) as [id, layer]}
|
|
||||||
{#if layer.layerType === 'overlay'}
|
|
||||||
<Select.Item value={id}>{layer.name}</Select.Item>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
<Label class="flex flex-row gap-6 items-center">
|
|
||||||
{$_('menu.style.opacity')}
|
|
||||||
<div class="p-2 pr-3 grow">
|
|
||||||
<Slider
|
|
||||||
bind:value={$overlayOpacity}
|
|
||||||
min={0.1}
|
|
||||||
max={1}
|
|
||||||
step={0.1}
|
|
||||||
disabled={$selectedOverlay === undefined}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if ($selectedOverlay) {
|
|
||||||
if ($map && isSelected($currentOverlays, $selectedOverlay.value)) {
|
|
||||||
try {
|
|
||||||
$map.removeImport($selectedOverlay.value);
|
|
||||||
} catch (e) {
|
|
||||||
// No reliable way to check if the map is ready to remove sources and layers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$opacities[$selectedOverlay.value] = value[0];
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
<Accordion.Item value="custom-layers">
|
|
||||||
<Accordion.Trigger>{$_('layers.custom_layers.title')}</Accordion.Trigger>
|
|
||||||
<Accordion.Content>
|
|
||||||
<ScrollArea>
|
|
||||||
<CustomLayers />
|
|
||||||
</ScrollArea>
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
</Accordion.Root>
|
|
||||||
</ScrollArea>
|
|
||||||
</Sheet.Header>
|
|
||||||
</Sheet.Content>
|
|
||||||
</Sheet.Root>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import LayerTreeNode from './LayerTreeNode.svelte';
|
|
||||||
import { type LayerTreeType } from '$lib/assets/layers';
|
|
||||||
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
|
||||||
|
|
||||||
export let layerTree: LayerTreeType;
|
|
||||||
export let name: string;
|
|
||||||
export let selected: string | undefined = undefined;
|
|
||||||
export let multiple: boolean = false;
|
|
||||||
|
|
||||||
export let checked: LayerTreeType = {};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form>
|
|
||||||
<fieldset class="min-w-64 mb-1">
|
|
||||||
<CollapsibleTree nohover={true}>
|
|
||||||
<LayerTreeNode {name} node={layerTree} bind:selected {multiple} bind:checked />
|
|
||||||
</CollapsibleTree>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
|
||||||
import CollapsibleTreeNode from '../collapsible-tree/CollapsibleTreeNode.svelte';
|
|
||||||
|
|
||||||
import { type LayerTreeType } from '$lib/assets/layers';
|
|
||||||
import { anySelectedLayer } from './utils';
|
|
||||||
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
import { settings } from '$lib/db';
|
|
||||||
import { beforeUpdate } from 'svelte';
|
|
||||||
|
|
||||||
export let name: string;
|
|
||||||
export let node: LayerTreeType;
|
|
||||||
export let selected: string | undefined = undefined;
|
|
||||||
export let multiple: boolean = false;
|
|
||||||
|
|
||||||
export let checked: LayerTreeType;
|
|
||||||
|
|
||||||
const { customLayers } = settings;
|
|
||||||
|
|
||||||
beforeUpdate(() => {
|
|
||||||
if (checked !== undefined) {
|
|
||||||
Object.keys(node).forEach((id) => {
|
|
||||||
if (!checked.hasOwnProperty(id)) {
|
|
||||||
if (typeof node[id] == 'boolean') {
|
|
||||||
checked[id] = false;
|
|
||||||
} else {
|
|
||||||
checked[id] = {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-[3px]">
|
|
||||||
{#each Object.keys(node) as id}
|
|
||||||
{#if typeof node[id] == 'boolean'}
|
|
||||||
{#if node[id]}
|
|
||||||
<div class="flex flex-row items-center gap-2 first:mt-0.5 h-4">
|
|
||||||
{#if multiple}
|
|
||||||
<Checkbox
|
|
||||||
id="{name}-{id}"
|
|
||||||
{name}
|
|
||||||
value={id}
|
|
||||||
bind:checked={checked[id]}
|
|
||||||
class="scale-90"
|
|
||||||
aria-label={$_(`layers.label.${id}`)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<input id="{name}-{id}" type="radio" {name} value={id} bind:group={selected} />
|
|
||||||
{/if}
|
|
||||||
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
|
||||||
{#if $customLayers.hasOwnProperty(id)}
|
|
||||||
{$customLayers[id].name}
|
|
||||||
{:else}
|
|
||||||
{$_(`layers.label.${id}`)}
|
|
||||||
{/if}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if anySelectedLayer(node[id])}
|
|
||||||
<CollapsibleTreeNode {id}>
|
|
||||||
<span slot="trigger">{$_(`layers.label.${id}`)}</span>
|
|
||||||
<div slot="content">
|
|
||||||
<svelte:self node={node[id]} {name} bind:selected {multiple} bind:checked={checked[id]} />
|
|
||||||
</div>
|
|
||||||
</CollapsibleTreeNode>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="postcss">
|
|
||||||
div :global(input[type='radio']) {
|
|
||||||
@apply appearance-none;
|
|
||||||
@apply w-4 h-4;
|
|
||||||
@apply border-[1.5px] border-primary;
|
|
||||||
@apply rounded-full;
|
|
||||||
@apply ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
|
|
||||||
@apply cursor-pointer;
|
|
||||||
@apply checked:bg-primary;
|
|
||||||
@apply checked:bg-clip-content;
|
|
||||||
@apply checked:p-0.5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import * as Card from '$lib/components/ui/card';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { selection } from '$lib/components/file-list/Selection';
|
|
||||||
import { PencilLine, MapPin } from 'lucide-svelte';
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
import { dbUtils } from '$lib/db';
|
|
||||||
import type { PopupItem } from '$lib/components/MapPopup';
|
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
|
||||||
|
|
||||||
export let poi: PopupItem<any>;
|
|
||||||
|
|
||||||
let tags = {};
|
|
||||||
let name = '';
|
|
||||||
$: if (poi) {
|
|
||||||
tags = JSON.parse(poi.item.tags);
|
|
||||||
if (tags.name !== undefined && tags.name !== '') {
|
|
||||||
name = tags.name;
|
|
||||||
} else {
|
|
||||||
name = $_(`layers.label.${poi.item.query}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
class="ml-auto p-1.5 h-8"
|
|
||||||
variant="outline"
|
|
||||||
href="https://www.openstreetmap.org/edit?editor=id&node={poi.item.id}"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<PencilLine size="16" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
|
|
||||||
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
|
|
||||||
{#if tags.image || tags['image:0']}
|
|
||||||
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
|
|
||||||
<!-- svelte-ignore a11y-missing-attribute -->
|
|
||||||
<img src={tags.image ?? tags['image:0']} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="grid grid-cols-[auto_auto] gap-x-3">
|
|
||||||
{#each Object.entries(tags) as [key, value]}
|
|
||||||
{#if key !== 'name' && !key.includes('image')}
|
|
||||||
<span class="font-mono">{key}</span>
|
|
||||||
{#if key === 'website' || key.startsWith('website:') || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
|
||||||
<a href={value} target="_blank" class="text-link underline">{value}</a>
|
|
||||||
{:else if key === 'phone' || key === 'contact:phone'}
|
|
||||||
<a href={'tel:' + value} class="text-link underline">{value}</a>
|
|
||||||
{:else if key === 'email' || key === 'contact:email'}
|
|
||||||
<a href={'mailto:' + value} class="text-link underline">{value}</a>
|
|
||||||
{:else}
|
|
||||||
<span>{value}</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
<Button
|
|
||||||
class="mt-2"
|
|
||||||
variant="outline"
|
|
||||||
disabled={$selection.size === 0}
|
|
||||||
on:click={() => {
|
|
||||||
let desc = Object.entries(tags)
|
|
||||||
.map(([key, value]) => `${key}: ${value}`)
|
|
||||||
.join('\n');
|
|
||||||
dbUtils.addOrUpdateWaypoint({
|
|
||||||
attributes: {
|
|
||||||
lat: poi.item.lat,
|
|
||||||
lon: poi.item.lon
|
|
||||||
},
|
|
||||||
name: name,
|
|
||||||
desc: desc,
|
|
||||||
cmt: desc,
|
|
||||||
sym: poi.item.sym
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MapPin size="16" class="mr-1" />
|
|
||||||
{$_('toolbar.waypoint.add')}
|
|
||||||
</Button>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import type { LayerTreeType } from "$lib/assets/layers";
|
|
||||||
import { writable } from "svelte/store";
|
|
||||||
|
|
||||||
export function anySelectedLayer(node: LayerTreeType) {
|
|
||||||
return Object.keys(node).find((id) => {
|
|
||||||
if (typeof node[id] == "boolean") {
|
|
||||||
if (node[id]) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (anySelectedLayer(node[id])) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}) !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLayers(node: LayerTreeType, layers: { [key: string]: boolean } = {}): { [key: string]: boolean } {
|
|
||||||
Object.keys(node).forEach((id) => {
|
|
||||||
if (typeof node[id] == "boolean") {
|
|
||||||
layers[id] = node[id];
|
|
||||||
} else {
|
|
||||||
getLayers(node[id], layers);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return layers;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSelected(node: LayerTreeType, id: string) {
|
|
||||||
return Object.keys(node).some((key) => {
|
|
||||||
if (key === id) {
|
|
||||||
return node[key];
|
|
||||||
}
|
|
||||||
if (typeof node[key] !== "boolean" && isSelected(node[key], id)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toggle(node: LayerTreeType, id: string) {
|
|
||||||
Object.keys(node).forEach((key) => {
|
|
||||||
if (key === id) {
|
|
||||||
node[key] = !node[key];
|
|
||||||
} else if (typeof node[key] !== "boolean") {
|
|
||||||
toggle(node[key], id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const customBasemapUpdate = writable(0);
|
|
||||||
18
website/src/lib/components/map/CoordinatesPopup.svelte
Normal file
18
website/src/lib/components/map/CoordinatesPopup.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
import { trackpointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
||||||
|
import { TrackPoint } from 'gpx';
|
||||||
|
|
||||||
|
map.onLoad((map_) => {
|
||||||
|
map_.on('contextmenu', (e) => {
|
||||||
|
trackpointPopup?.setItem({
|
||||||
|
item: new TrackPoint({
|
||||||
|
attributes: {
|
||||||
|
lat: e.lngLat.lat,
|
||||||
|
lon: e.lngLat.lng,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
239
website/src/lib/components/map/Map.svelte
Normal file
239
website/src/lib/components/map/Map.svelte
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
|
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
|
||||||
|
let {
|
||||||
|
accessToken = PUBLIC_MAPBOX_TOKEN,
|
||||||
|
geolocate = true,
|
||||||
|
geocoder = true,
|
||||||
|
hash = true,
|
||||||
|
class: className = '',
|
||||||
|
}: {
|
||||||
|
accessToken?: string;
|
||||||
|
geolocate?: boolean;
|
||||||
|
geocoder?: boolean;
|
||||||
|
hash?: boolean;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
mapboxgl.accessToken = accessToken;
|
||||||
|
|
||||||
|
let webgl2Supported = $state(true);
|
||||||
|
let embeddedApp = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let gl = document.createElement('canvas').getContext('webgl2');
|
||||||
|
if (!gl) {
|
||||||
|
webgl2Supported = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.top !== window.self && !page.route.id?.includes('embed')) {
|
||||||
|
embeddedApp = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let language = page.params.language;
|
||||||
|
if (language === 'zh') {
|
||||||
|
language = 'zh-Hans';
|
||||||
|
} else if (language?.includes('-')) {
|
||||||
|
language = language.split('-')[0];
|
||||||
|
} else if (language === '' || language === undefined) {
|
||||||
|
language = 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
map.init(PUBLIC_MAPBOX_TOKEN, language, hash, geocoder, geolocate);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
map.destroy();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={className}>
|
||||||
|
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported &&
|
||||||
|
!embeddedApp
|
||||||
|
? 'hidden'
|
||||||
|
: ''} {embeddedApp ? 'z-30' : ''}"
|
||||||
|
>
|
||||||
|
{#if !webgl2Supported}
|
||||||
|
<p>{i18n._('webgl2_required')}</p>
|
||||||
|
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
||||||
|
{i18n._('enable_webgl2')}
|
||||||
|
</Button>
|
||||||
|
{:else if embeddedApp}
|
||||||
|
<p>The app cannot be embedded in an iframe.</p>
|
||||||
|
<Button href="https://gpx.studio/help/integration" target="_blank">
|
||||||
|
Learn how to create a map for your website
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
@reference "../../../app.css";
|
||||||
|
|
||||||
|
div :global(.mapboxgl-map) {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
|
||||||
|
@apply shadow-md;
|
||||||
|
@apply bg-background;
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-icon) {
|
||||||
|
@apply dark:brightness-[4.7];
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder) {
|
||||||
|
@apply flex;
|
||||||
|
@apply flex-row;
|
||||||
|
@apply w-fit;
|
||||||
|
@apply min-w-fit;
|
||||||
|
@apply items-center;
|
||||||
|
@apply shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.suggestions) {
|
||||||
|
@apply shadow-md;
|
||||||
|
@apply bg-background;
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
|
||||||
|
@apply text-foreground;
|
||||||
|
@apply hover:text-accent-foreground;
|
||||||
|
@apply hover:bg-accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
|
||||||
|
@apply bg-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--button) {
|
||||||
|
@apply bg-transparent;
|
||||||
|
@apply hover:bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--icon) {
|
||||||
|
@apply fill-foreground;
|
||||||
|
@apply hover:fill-accent-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
|
||||||
|
@apply relative;
|
||||||
|
@apply top-0;
|
||||||
|
@apply left-0;
|
||||||
|
@apply my-2;
|
||||||
|
@apply w-[29px];
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--input) {
|
||||||
|
@apply relative;
|
||||||
|
@apply w-64;
|
||||||
|
@apply py-0;
|
||||||
|
@apply pl-2;
|
||||||
|
@apply focus:outline-none;
|
||||||
|
@apply transition-[width];
|
||||||
|
@apply duration-200;
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
|
||||||
|
@apply w-0;
|
||||||
|
@apply p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-top-right) {
|
||||||
|
@apply z-40;
|
||||||
|
@apply flex;
|
||||||
|
@apply flex-col;
|
||||||
|
@apply items-end;
|
||||||
|
@apply h-full;
|
||||||
|
@apply overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
|
||||||
|
@apply bottom-[42px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
||||||
|
@apply bottom-[42px];
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-attrib) {
|
||||||
|
@apply dark:bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
||||||
|
@apply dark:bg-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-attrib-button) {
|
||||||
|
@apply dark:bg-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
|
||||||
|
@apply dark:bg-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-attrib a) {
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup) {
|
||||||
|
@apply w-fit;
|
||||||
|
@apply z-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-content) {
|
||||||
|
@apply p-0;
|
||||||
|
@apply bg-transparent;
|
||||||
|
@apply shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
|
||||||
|
@apply border-b-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
|
||||||
|
@apply border-b-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
|
||||||
|
@apply border-b-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
|
||||||
|
@apply border-t-background;
|
||||||
|
@apply drop-shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
|
||||||
|
@apply border-t-background;
|
||||||
|
@apply drop-shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
|
||||||
|
@apply border-t-background;
|
||||||
|
@apply drop-shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
|
||||||
|
@apply border-r-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
||||||
|
@apply border-l-background;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
34
website/src/lib/components/map/MapPopup.svelte
Normal file
34
website/src/lib/components/map/MapPopup.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { TrackPoint, Waypoint } from 'gpx';
|
||||||
|
import WaypointPopup from '$lib/components/map/gpx-layer/WaypointPopup.svelte';
|
||||||
|
import TrackpointPopup from '$lib/components/map/gpx-layer/TrackpointPopup.svelte';
|
||||||
|
import OverpassPopup from '$lib/components/map/layer-control/OverpassPopup.svelte';
|
||||||
|
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||||
|
import type { Writable } from 'svelte/store';
|
||||||
|
|
||||||
|
let {
|
||||||
|
item,
|
||||||
|
onContainerReady,
|
||||||
|
}: { item: Writable<PopupItem | null>; onContainerReady: (div: HTMLDivElement) => void } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
let container: HTMLDivElement | null = $state(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (container) {
|
||||||
|
onContainerReady(container);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={container}>
|
||||||
|
{#if $item}
|
||||||
|
{#if $item.item instanceof Waypoint}
|
||||||
|
<WaypointPopup waypoint={$item} />
|
||||||
|
{:else if $item.item instanceof TrackPoint}
|
||||||
|
<TrackpointPopup trackpoint={$item} />
|
||||||
|
{:else}
|
||||||
|
<OverpassPopup poi={$item} />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CustomControl from './custom-control';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
import { onMount, type Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
position = 'top-right',
|
||||||
|
class: className = '',
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||||
|
class: string;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let control: CustomControl | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
map.onLoad((map: mapboxgl.Map) => {
|
||||||
|
if (position.includes('right')) container.classList.add('float-right');
|
||||||
|
else container.classList.add('float-left');
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
if (control === null) {
|
||||||
|
control = new CustomControl(container);
|
||||||
|
}
|
||||||
|
map.addControl(control, position);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={container}
|
||||||
|
class="{className ||
|
||||||
|
''} clear-both translate-0 m-[10px] mb-0 last:mb-[10px] pointer-events-auto bg-background rounded shadow-md hidden"
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { ClipboardCopy } from '@lucide/svelte';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import type { Coordinates } from 'gpx';
|
||||||
|
|
||||||
|
let {
|
||||||
|
coordinates,
|
||||||
|
onCopy = () => {},
|
||||||
|
class: className = '',
|
||||||
|
}: {
|
||||||
|
coordinates: Coordinates;
|
||||||
|
onCopy?: () => void;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
class="justify-start {className}"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}`
|
||||||
|
);
|
||||||
|
onCopy();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClipboardCopy size="16" />
|
||||||
|
{i18n._('menu.copy_coordinates')}
|
||||||
|
</Button>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user