mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-17 08:42:28 +00:00
Compare commits
998
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bfd319758 | ||
|
|
187296a277 | ||
|
|
55a8b020e0 | ||
|
|
1ba5436af3 | ||
|
|
2e1ca00afa | ||
|
|
97d597e139 | ||
|
|
41b9116c6a | ||
|
|
ce8398f749 | ||
|
|
3c2055b50e | ||
|
|
1d13972014 | ||
|
|
890bcd765a | ||
|
|
4795513344 | ||
|
|
d84ca47745 | ||
|
|
9adb8e7221 | ||
|
|
a2b4d89150 | ||
|
|
77929cfa04 | ||
|
|
dc36de2a48 | ||
|
|
da9abbae5e | ||
|
|
3bb553d6b5 | ||
|
|
83b76bb838 | ||
|
|
42a55e6759 | ||
|
|
b085a1d476 | ||
|
|
840e943626 | ||
|
|
16e2e0f679 | ||
|
|
0f9557ea82 | ||
|
|
67a4c090bd | ||
|
|
606a0e974a | ||
|
|
e8c4eb92f9 | ||
|
|
f0851086c0 | ||
|
|
bec1ce4c9b | ||
|
|
b29c4cdb08 | ||
|
|
63ba8596a7 | ||
|
|
82d2f79da0 | ||
|
|
f642d60f42 | ||
|
|
24b877c4ee | ||
|
|
e07ec97e12 | ||
|
|
290f4d8c44 | ||
|
|
a48608840f | ||
|
|
df07892e76 | ||
|
|
594dd8979a | ||
|
|
29850c1c8f | ||
|
|
adbe4a2693 | ||
|
|
b843a18441 | ||
|
|
da8a9e3d84 | ||
|
|
c6bd5b06e8 | ||
|
|
c04367dd86 | ||
|
|
5e2f506120 | ||
|
|
c536e6247b | ||
|
|
594d714b7f | ||
|
|
543fecadb8 | ||
|
|
017d8521d6 | ||
|
|
78e9d45072 | ||
|
|
7651d859c4 | ||
|
|
ec448b72e2 | ||
|
|
bf946f179a | ||
|
|
9ff9b5475a | ||
|
|
55f1d275b7 | ||
|
|
1d534881f8 | ||
|
|
03cbacfbb4 | ||
|
|
9d0905959b | ||
|
|
b7ca462f58 | ||
|
|
9015da4876 | ||
|
|
f458a1104f | ||
|
|
caba9388af | ||
|
|
763a7406ce | ||
|
|
68c8933c35 | ||
|
|
84b1558b2e | ||
|
|
cd870f0186 | ||
|
|
9ac2a2b69c | ||
|
|
86aba8f4b1 | ||
|
|
74b674e8e8 | ||
|
|
f1904bc12f | ||
|
|
9afba53e5d | ||
|
|
fd76e0fa46 | ||
|
|
438a87f06f | ||
|
|
a994e16f51 | ||
|
|
639e7491eb | ||
|
|
891a08a3cc | ||
|
|
0c07164dec | ||
|
|
f133d55889 | ||
|
|
fbb636cb06 | ||
|
|
bddceb28aa | ||
|
|
bff84fa24c | ||
|
|
64914f9aa0 | ||
|
|
2abf46c747 | ||
|
|
888f41e979 | ||
|
|
17533586ef | ||
|
|
c6ad088947 | ||
|
|
87d6d5f30e | ||
|
|
dbe894b3a3 | ||
|
|
a921d4b9cd | ||
|
|
b7db73cf77 | ||
|
|
5868595a6c | ||
|
|
eb8e09b835 | ||
|
|
41dc853f65 | ||
|
|
27c0a57cc1 | ||
|
|
497ac120ec | ||
|
|
a486016d96 | ||
|
|
cc5f5653ef | ||
|
|
20222c450d | ||
|
|
69049e1345 | ||
|
|
ed6264444f | ||
|
|
86f4fc3394 | ||
|
|
565589fc94 | ||
|
|
4ef92e5abd | ||
|
|
7f8751fa38 | ||
|
|
befcba00b4 | ||
|
|
26e4b637a2 | ||
|
|
75e3bc6f93 | ||
|
|
70bb125076 | ||
|
|
e0a4fc186a | ||
|
|
6a9d30e8ea | ||
|
|
1e6a12eeda | ||
|
|
31912da76f | ||
|
|
fe8896e870 | ||
|
|
c99517572e | ||
|
|
0b7f30a7c4 | ||
|
|
fcfaf043c4 | ||
|
|
0393a330a6 | ||
|
|
2fdb58bc7d | ||
|
|
c758bda1a9 | ||
|
|
88e301e2a2 | ||
|
|
161c664e08 | ||
|
|
a0408ec798 | ||
|
|
480d5586b9 | ||
|
|
7b49018593 | ||
|
|
4b447a9b9d | ||
|
|
443feb2bfa | ||
|
|
8710101a78 | ||
|
|
2c20148e64 | ||
|
|
ffc0e84788 | ||
|
|
2d2004e447 | ||
|
|
4a7cfc113d | ||
|
|
f03ad5a0e9 | ||
|
|
7f5b83f9d8 | ||
|
|
5a8f93e225 | ||
|
|
668b0f6b23 | ||
|
|
fc1eb5a408 | ||
|
|
561c7e22b3 | ||
|
|
ce067d4ff0 | ||
|
|
7361b54255 | ||
|
|
9e2334ca83 | ||
|
|
08d1f1d5e4 | ||
|
|
f3bf4f0096 | ||
|
|
b4094d0a5b | ||
|
|
36122b4ac5 | ||
|
|
dd9aba3adb | ||
|
|
bd40fbae74 | ||
|
|
690cbc49cc | ||
|
|
36b16ddeef | ||
|
|
16b8988fa7 | ||
|
|
40f97b7c35 | ||
|
|
54b3113480 | ||
|
|
7e9140492a | ||
|
|
79c0aed54f | ||
|
|
cb5a74de00 | ||
|
|
31f25f346a | ||
|
|
b3a11125a5 | ||
|
|
71cdc03da5 | ||
|
|
694e73a677 | ||
|
|
5aaacccef9 | ||
|
|
f2bf043900 | ||
|
|
ba251fe407 | ||
|
|
421aa9dc69 | ||
|
|
b4a7f1353b | ||
|
|
30de3c6db5 | ||
|
|
8aafa26238 | ||
|
|
5768391305 | ||
|
|
1d204bacf2 | ||
|
|
4d1d5d48c0 | ||
|
|
5a6321535e | ||
|
|
e077de9f48 | ||
|
|
7586f03998 | ||
|
|
af8c22dcda | ||
|
|
3dce5dc617 | ||
|
|
84b90e1026 | ||
|
|
d507586eed | ||
|
|
57afaedf83 | ||
|
|
48063b9066 | ||
|
|
452d356599 | ||
|
|
25eda8041e | ||
|
|
ae4d5356eb | ||
|
|
3343bb906e | ||
|
|
7b17900160 | ||
|
|
d5f1fe1c7b | ||
|
|
553f73f992 | ||
|
|
c8cedf2e2c | ||
|
|
a751817847 | ||
|
|
d1ef12db8d | ||
|
|
43d73edf29 | ||
|
|
5a0b8c376c | ||
|
|
9743fd460e | ||
|
|
f70f92a176 | ||
|
|
1a4175446c | ||
|
|
ed6dfab4c1 | ||
|
|
6a6e1105c0 | ||
|
|
1677fe254b | ||
|
|
02efe708c2 | ||
|
|
7dc834f506 | ||
|
|
57c4958ff2 | ||
|
|
03e59a8cce | ||
|
|
f3d18f09a0 | ||
|
|
c1dbd984e6 | ||
|
|
e4f227221d | ||
|
|
34139974aa | ||
|
|
408b2422e6 | ||
|
|
b59cb9e200 | ||
|
|
bd5cb65d0f | ||
|
|
4cfe487af0 | ||
|
|
4da2e39e32 | ||
|
|
5ff11a32c9 | ||
|
|
01a7ec916e | ||
|
|
dd94a7d613 | ||
|
|
089b88c62d | ||
|
|
c9ca75e2e8 | ||
|
|
091f6a3ed0 | ||
|
|
d6c9fb1025 | ||
|
|
88abd72a41 | ||
|
|
1137e851ce | ||
|
|
b8c1500aad | ||
|
|
bfd0d90abc | ||
|
|
dba01e1826 | ||
|
|
2189c76edd | ||
|
|
6f8c9d66db | ||
|
|
9408ce10c7 | ||
|
|
9895c3c304 | ||
|
|
0ab3b77db8 | ||
|
|
d13e7e7a0a | ||
|
|
9c6e03f4a8 | ||
|
|
2a4dfe010e | ||
|
|
f42a916c25 | ||
|
|
772b810fa8 | ||
|
|
4d4d10d5c2 | ||
|
|
0e4c7dbe64 | ||
|
|
e96b544a75 | ||
|
|
375204c379 | ||
|
|
d76c03af4f | ||
|
|
200a6586ba | ||
|
|
a01ca79a82 | ||
|
|
c91baf7c83 | ||
|
|
5062de8ddf | ||
|
|
f0f1ecb2df | ||
|
|
2eb6ef6f03 | ||
|
|
f7c0805161 | ||
|
|
4e18e3c8a0 | ||
|
|
59f31caf26 | ||
|
|
f24956c58d | ||
|
|
9019317e5c | ||
|
|
2a0227c1de | ||
|
|
f70db42b91 | ||
|
|
9cd87742f0 | ||
|
|
5dcb93ca5d | ||
|
|
256d62b29b | ||
|
|
595ea8e2d3 | ||
|
|
9ca46b9d35 | ||
|
|
d3e733aa3e | ||
|
|
a011768d2d | ||
|
|
4b45b5d716 | ||
|
|
ebe9681c12 | ||
|
|
51c85e4cd5 | ||
|
|
2e171dfbee | ||
|
|
a6a3917986 | ||
|
|
21f2448213 | ||
|
|
e7a1d0488b | ||
|
|
22b8e0edb4 | ||
|
|
d062a38e8f | ||
|
|
affa59130f | ||
|
|
3c816567bc | ||
|
|
7c2e24bbc4 | ||
|
|
e92e48ffde | ||
|
|
4ce7777b86 | ||
|
|
bc130ad867 | ||
|
|
867b6a6ac7 | ||
|
|
e585fd084c | ||
|
|
b47bb4a771 | ||
|
|
9cff71fed3 | ||
|
|
e76040e416 | ||
|
|
1facf50621 | ||
|
|
57f3cc8bc0 | ||
|
|
1ab3fe1c4a | ||
|
|
10cff632fd | ||
|
|
4105687c0a | ||
|
|
8fe6565527 | ||
|
|
69b018022d | ||
|
|
467cb2e589 | ||
|
|
f13d8c1e22 | ||
|
|
e230d55b82 | ||
|
|
46fcdb4bb2 | ||
|
|
429212ef23 | ||
|
|
4ea0ea6a7a | ||
|
|
2e3ce83605 | ||
|
|
fda908dd0d | ||
|
|
cad77e2b10 | ||
|
|
3542a7c24d | ||
|
|
0d6d161e23 | ||
|
|
89a2e0086b | ||
|
|
cd443faf61 | ||
|
|
bfc56b02a8 | ||
|
|
25bafc6bf1 | ||
|
|
6387580626 | ||
|
|
09b8aa65fc | ||
|
|
6c15193f32 | ||
|
|
4442e29b66 | ||
|
|
b6f96d9f4d | ||
|
|
36b66100f9 | ||
|
|
49d8143cc6 | ||
|
|
fc279fecaf | ||
|
|
bd307daa57 | ||
|
|
7a72f44722 | ||
|
|
8e63fc6946 | ||
|
|
3a65f8dc16 | ||
|
|
d624352a0b | ||
|
|
3fb597a774 | ||
|
|
85e46bc524 | ||
|
|
d3790878b3 | ||
|
|
e648e50f1b | ||
|
|
cb6cede0b6 | ||
|
|
a0157ddc2a | ||
|
|
8f595fbc7b | ||
|
|
2e3b22c5fa | ||
|
|
42b968372b | ||
|
|
415e7b492f | ||
|
|
2ea8e46723 | ||
|
|
977c6c6dde | ||
|
|
1e5db9dc6c | ||
|
|
252dc10e61 | ||
|
|
f9e2315ba1 | ||
|
|
0eca588280 | ||
|
|
33523bbfb9 | ||
|
|
110f23bdf1 | ||
|
|
50a5cb23f5 | ||
|
|
30e72db5ea | ||
|
|
c4c64c8fe8 | ||
|
|
df39350d7d | ||
|
|
5daacd3ed4 | ||
|
|
0f7f64fb2f | ||
|
|
b09a1fdcb7 | ||
|
|
e5d45dee3a | ||
|
|
f3270e19df | ||
|
|
1b9ad41c87 | ||
|
|
8c3365ef24 | ||
|
|
db5cbffb70 | ||
|
|
c6586f0eed | ||
|
|
f40bdc8ed9 | ||
|
|
683ac4e118 | ||
|
|
88c9abb78e | ||
|
|
1729a2f734 | ||
|
|
e5ad8bbb70 | ||
|
|
7f6acbfdbc | ||
|
|
2e070529e0 | ||
|
|
f4b31e5f0a | ||
|
|
f7f093a464 | ||
|
|
95cc340de5 | ||
|
|
51a003c816 | ||
|
|
977152139f | ||
|
|
c6798dbcd5 | ||
|
|
78833df95e | ||
|
|
099d941d2e | ||
|
|
ea58f378a9 | ||
|
|
4060884909 | ||
|
|
d9277c11d2 | ||
|
|
bcbc90820a | ||
|
|
e9caa95673 | ||
|
|
9cd6703b05 | ||
|
|
4233bd7771 | ||
|
|
0e2db441f2 | ||
|
|
571b101ea4 | ||
|
|
0b9dca61ab | ||
|
|
d8fa76d076 | ||
|
|
6116eef513 | ||
|
|
fabe987f2c | ||
|
|
af20880f37 | ||
|
|
44eeab0d4b | ||
|
|
b331900158 | ||
|
|
d74380404c | ||
|
|
3833a9cd6b | ||
|
|
74fdd943c9 | ||
|
|
ad5b772502 | ||
|
|
9bc941aa31 | ||
|
|
705df43047 | ||
|
|
b31e3bb710 | ||
|
|
4082d0a368 | ||
|
|
ce85286cdf | ||
|
|
415cf1a777 | ||
|
|
f249919ec8 | ||
|
|
054c9787d3 | ||
|
|
c1e88e2b5a | ||
|
|
f5efeb16c4 | ||
|
|
59afae7bca | ||
|
|
92c6339064 | ||
|
|
582ae233f2 | ||
|
|
3c3016a211 | ||
|
|
e24e1d9d3c | ||
|
|
85fb564be7 | ||
|
|
18f7db9eee | ||
|
|
ea0770fd11 | ||
|
|
9c28fab3f9 | ||
|
|
bac332a8c4 | ||
|
|
86d2542bd9 | ||
|
|
5f63ec884f | ||
|
|
0f87b33354 | ||
|
|
c7dc99a12f | ||
|
|
05c3c3f8f3 | ||
|
|
2437a43471 | ||
|
|
16cd812ba0 | ||
|
|
c036128720 | ||
|
|
645db15848 | ||
|
|
21e142ffc3 | ||
|
|
23a6b3db72 | ||
|
|
b87d109625 | ||
|
|
651bd295b9 | ||
|
|
01607e92b9 | ||
|
|
f40d54adbb | ||
|
|
60fb387495 | ||
|
|
a7df18723c | ||
|
|
4e39cc937a | ||
|
|
fe513c17ab | ||
|
|
f7fb88ed3d | ||
|
|
b3247c7cbe | ||
|
|
d490dc0a8b | ||
|
|
97e79c23f6 | ||
|
|
7803a29875 | ||
|
|
08093beed1 | ||
|
|
ae46ae89bc | ||
|
|
f7977dca39 | ||
|
|
0b344f3e08 | ||
|
|
6058154eca | ||
|
|
941016f04b | ||
|
|
7a15898087 | ||
|
|
6b4effa90f | ||
|
|
77d56e4a4c | ||
|
|
c4dd622e7b | ||
|
|
9f24535142 | ||
|
|
a45161fcb8 | ||
|
|
4379763a73 | ||
|
|
a25b376dfd | ||
|
|
fb429f6777 | ||
|
|
ae33227c3a | ||
|
|
2fedc61d1e | ||
|
|
6988a36dd1 | ||
|
|
60e1124970 | ||
|
|
a007684006 | ||
|
|
090a709522 | ||
|
|
9e06655214 | ||
|
|
3651825e79 | ||
|
|
dcf3160b58 | ||
|
|
fd014f42cd | ||
|
|
a760f2f7fc | ||
|
|
262c114b7d | ||
|
|
c90bdd83bb | ||
|
|
fdb2d37b12 | ||
|
|
7951837ecf | ||
|
|
d2e112a672 | ||
|
|
90e86077ba | ||
|
|
f69dfcdefe | ||
|
|
5cff7c4e72 | ||
|
|
0eaa833cdf | ||
|
|
db015d925e | ||
|
|
69fd47601e | ||
|
|
17521574f0 | ||
|
|
56650a76ae | ||
|
|
984a98e792 | ||
|
|
49eb0cf202 | ||
|
|
8e24a6b036 | ||
|
|
d07bf6d699 | ||
|
|
877d12c676 | ||
|
|
ca629f625a | ||
|
|
087dd9a4b6 | ||
|
|
d9a967c072 | ||
|
|
6d522c82c3 | ||
|
|
867af98083 | ||
|
|
1be058d831 | ||
|
|
71bc044ae5 | ||
|
|
d660c50ade | ||
|
|
3fd733d903 | ||
|
|
7703b2361d | ||
|
|
68fdb9ebc6 | ||
|
|
b6513343be | ||
|
|
cc95ff1c55 | ||
|
|
c954ee0fde | ||
|
|
1ada5e5d18 | ||
|
|
f5244c3d93 | ||
|
|
1f17776cd4 | ||
|
|
ebd4f36f94 | ||
|
|
dbb9b5f254 | ||
|
|
4301472cb2 | ||
|
|
15e7954321 | ||
|
|
32d1de08e9 | ||
|
|
ea88663dce | ||
|
|
97aecaf890 | ||
|
|
b16688e1b7 | ||
|
|
5ac856251b | ||
|
|
62a9aacd85 | ||
|
|
dff0b55a8c | ||
|
|
3b21c75b13 | ||
|
|
2c7cc4b8e5 | ||
|
|
bcf8c0e35c | ||
|
|
11d2936fca | ||
|
|
5e84429e24 | ||
|
|
48c88b2d7e | ||
|
|
e5185c0b77 | ||
|
|
15773d3aba | ||
|
|
b577837446 | ||
|
|
324e234b2a | ||
|
|
d40fefb0ea | ||
|
|
7e05568549 | ||
|
|
0249a52d1c | ||
|
|
5df1c5b09b | ||
|
|
953ec8fe31 | ||
|
|
6054afebdc | ||
|
|
04f356e119 | ||
|
|
a6ebefbb30 | ||
|
|
9a1edbe1fa | ||
|
|
c46d74be54 | ||
|
|
72e949586a | ||
|
|
68dacad741 | ||
|
|
7e6505ca73 | ||
|
|
362d83f504 | ||
|
|
e4879736b7 | ||
|
|
68fe6628c7 | ||
|
|
f39ef569be | ||
|
|
42c199376a | ||
|
|
cd6b774a8c | ||
|
|
62598f94f8 | ||
|
|
aa3b46141f | ||
|
|
dcaa2aaeab | ||
|
|
e36f5e47da | ||
|
|
578d7b41b4 | ||
|
|
c4f81ce279 | ||
|
|
02a7dbea85 | ||
|
|
0305d3fe36 | ||
|
|
84fd034197 | ||
|
|
61b48e2048 | ||
|
|
c91f85389c | ||
|
|
f3683355a9 | ||
|
|
a86da886f4 | ||
|
|
7bf8d42eb8 | ||
|
|
88533e29b9 | ||
|
|
8299dcc881 | ||
|
|
0d0c250fea | ||
|
|
eb8d86616b | ||
|
|
1edc90810f | ||
|
|
c011d84a35 | ||
|
|
b97f62ac12 | ||
|
|
200a38bdc0 | ||
|
|
e12c53a90e | ||
|
|
7892916f56 | ||
|
|
5d681beab3 | ||
|
|
a54a2affb3 | ||
|
|
120062bb85 | ||
|
|
a0754d2bd7 | ||
|
|
f4b13a84d4 | ||
|
|
1c98e27e4d | ||
|
|
6067e25df8 | ||
|
|
304c50a247 | ||
|
|
274ad8eac2 | ||
|
|
9946a3fc0e | ||
|
|
622d6db968 | ||
|
|
d756ce0656 | ||
|
|
316e776355 | ||
|
|
f861b7ad99 | ||
|
|
01382e98f3 | ||
|
|
3371442bbc | ||
|
|
a81a804364 | ||
|
|
b24cf5c946 | ||
|
|
3d60213644 | ||
|
|
a23822c9df | ||
|
|
9fa69758f1 | ||
|
|
c892d3f134 | ||
|
|
73271501dc | ||
|
|
a62ea520e7 | ||
|
|
a2a0b3c71e | ||
|
|
51bedbe003 | ||
|
|
7062a3e657 | ||
|
|
209d95d5da | ||
|
|
95ee74ab2b | ||
|
|
9f6b268dce | ||
|
|
96e8ebcc10 | ||
|
|
ca261c7037 | ||
|
|
a95fe13b31 | ||
|
|
c3fe76adf9 | ||
|
|
e9b9d51a6e | ||
|
|
aff66205e9 | ||
|
|
aa0c9d65ae | ||
|
|
423bd2122f | ||
|
|
7533910114 | ||
|
|
0d4d377c22 | ||
|
|
3a576b3ea5 | ||
|
|
27f37744a0 | ||
|
|
012ecad0bb | ||
|
|
49b5d5e961 | ||
|
|
01ca48fe96 | ||
|
|
5abe5045b2 | ||
|
|
8c79a577b9 | ||
|
|
5b18f1016f | ||
|
|
bda58312bb | ||
|
|
eff352a003 | ||
|
|
3109d4ff82 | ||
|
|
c44eda3af6 | ||
|
|
33e8c41c6f | ||
|
|
e0b515ba2b | ||
|
|
c6b6ad41fb | ||
|
|
99576238cd | ||
|
|
db712650ef | ||
|
|
d77e7b07eb | ||
|
|
47b35fad3f | ||
|
|
720105ca92 | ||
|
|
ce656068e3 | ||
|
|
e69ba76e7d | ||
|
|
d1bab213a3 | ||
|
|
016a9341ad | ||
|
|
81f407ad5f | ||
|
|
37ff2f6bdb | ||
|
|
13cf1a9551 | ||
|
|
799aaac449 | ||
|
|
fbe430e4cf | ||
|
|
511746620e | ||
|
|
fed5b648f8 | ||
|
|
1f601743f6 | ||
|
|
db3ccf5da7 | ||
|
|
038898d580 | ||
|
|
dafbc6bc51 | ||
|
|
3d8271e7a1 | ||
|
|
e2ade95219 | ||
|
|
604971a576 | ||
|
|
76173103af | ||
|
|
3fcbc60232 | ||
|
|
835bdb39d0 | ||
|
|
200f09c5d2 | ||
|
|
99a008f122 | ||
|
|
9b71e89ba0 | ||
|
|
9955369cfa | ||
|
|
6ef6ffab66 | ||
|
|
0dd46f0f20 | ||
|
|
cce0d85dd4 | ||
|
|
1ed3eae038 | ||
|
|
2854c24197 | ||
|
|
4b90ddda2a | ||
|
|
552c39e583 | ||
|
|
5176e459b5 | ||
|
|
39515d0600 | ||
|
|
5314949394 | ||
|
|
59e5be749c | ||
|
|
da252a0070 | ||
|
|
b6199e430c | ||
|
|
e260a68c26 | ||
|
|
570cb2deaf | ||
|
|
7a36f03fb5 | ||
|
|
6c3058ba97 | ||
|
|
48a1034c12 | ||
|
|
aaddb50ab9 | ||
|
|
a947586cfe | ||
|
|
f3cfa14a59 | ||
|
|
a2c0a77c53 | ||
|
|
c078f9d5cb | ||
|
|
08eab8a157 | ||
|
|
9c36f234bc | ||
|
|
36b81d0e2a | ||
|
|
4432c14377 | ||
|
|
99e6dd5ca3 | ||
|
|
6327a25aec | ||
|
|
40989de7f5 | ||
|
|
58af44e795 | ||
|
|
4910cc05f8 | ||
|
|
164ee24d16 | ||
|
|
0ffda4ab7c | ||
|
|
4694a6271d | ||
|
|
2f50bc747a | ||
|
|
a13f621a81 | ||
|
|
0cc520cc67 | ||
|
|
c9451c3f2d | ||
|
|
8da53ffda2 | ||
|
|
4319761687 | ||
|
|
a1f3227cd9 | ||
|
|
b07f87c920 | ||
|
|
9c8f23eb64 | ||
|
|
36c6c623de | ||
|
|
e334419e24 | ||
|
|
01240c4f2a | ||
|
|
431a9ce827 | ||
|
|
20ab41c3b4 | ||
|
|
3f4ea27be2 | ||
|
|
5bb5b2f8c8 | ||
|
|
e9bb9e27bb | ||
|
|
ee1dd1fae7 | ||
|
|
738530a960 | ||
|
|
16023b0688 | ||
|
|
bce7b5984f | ||
|
|
4e5d7d391a | ||
|
|
0554a85e01 | ||
|
|
2d232b3c4b | ||
|
|
b2a5462372 | ||
|
|
9d61f51270 | ||
|
|
a0eb7d61cc | ||
|
|
9861b319f4 | ||
|
|
b04e0f10b2 | ||
|
|
e6d089b34b | ||
|
|
9df014e986 | ||
|
|
39b8d2e70d | ||
|
|
59710d2e1a | ||
|
|
712dc9bb34 | ||
|
|
5c338d53ae | ||
|
|
ec3eb387e5 | ||
|
|
722cf58486 | ||
|
|
17e5347d55 | ||
|
|
2e828dfde3 | ||
|
|
1b035bcde3 | ||
|
|
30981130c9 | ||
|
|
6db8696a36 | ||
|
|
9c83dcafa7 | ||
|
|
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 |
No files matched your search
+1
-1
@@ -1 +1 @@
|
|||||||
ko_fi: gpxstudio
|
open_collective: gpxstudio
|
||||||
@@ -8,30 +8,42 @@ 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: website/package-lock.json
|
||||||
gpx/package-lock.json
|
|
||||||
website/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies for gpx
|
- name: Install Rust
|
||||||
run: npm install --prefix gpx
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: wasm32-unknown-unknown
|
||||||
|
|
||||||
- name: Build gpx
|
- name: Install wasm-pack
|
||||||
run: npm run build --prefix gpx
|
uses: taiki-e/install-action@v2
|
||||||
|
with:
|
||||||
|
tool: wasm-pack
|
||||||
|
|
||||||
|
- name: Test gpx-rs
|
||||||
|
run: cargo test
|
||||||
|
|
||||||
|
- name: Build wasm
|
||||||
|
run: wasm-pack build --target bundler
|
||||||
|
working-directory: wasm
|
||||||
|
|
||||||
- name: Install dependencies for website
|
- name: Install dependencies for website
|
||||||
run: npm install --prefix website
|
run: npm install --prefix website
|
||||||
|
|
||||||
|
- name: Test website
|
||||||
|
run: npm test --prefix website
|
||||||
|
|
||||||
- name: Create env file
|
- name: Create env file
|
||||||
run: |
|
run: |
|
||||||
touch website/.env
|
touch website/.env
|
||||||
echo PUBLIC_MAPBOX_TOKEN=${{ secrets.PUBLIC_MAPBOX_TOKEN }} >> website/.env
|
echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env
|
||||||
cat website/.env
|
cat website/.env
|
||||||
|
|
||||||
- name: Build website
|
- name: Build website
|
||||||
@@ -41,7 +53,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/'
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
target/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
website/src/lib/components/ui
|
||||||
|
website/src/lib/docs/**/*.mdx
|
||||||
|
**/*.webmanifest
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "**/*.svelte",
|
||||||
|
"options": {
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"svelte.svelte-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
Vendored
+13
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Generated
+5252
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["gpx-rs", "wasm", "tauri"]
|
||||||
|
# `cargo test` at the root only tests the engine: the others need their own tooling
|
||||||
|
default-members = ["gpx-rs"]
|
||||||
|
resolver = "3"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2024 gpx.studio
|
Copyright (c) 2026 gpx.studio
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
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
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
[**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files.
|
[**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
This repository contains the source code of the website.
|
This repository contains the source code of the website.
|
||||||
|
|
||||||
@@ -25,27 +25,29 @@ Any help is greatly appreciated!
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
The code is split into two parts:
|
The code is split into four parts:
|
||||||
- `gpx`: a Typescript library for parsing and manipulating GPX files,
|
|
||||||
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
|
|
||||||
|
|
||||||
You will need [Node.js](https://nodejs.org/) to build and run these two parts.
|
- `gpx-rs`: a Rust library for parsing and manipulating GPX files (the engine), and the interface to it that the next two share (`gpx_engine::api`),
|
||||||
|
- `wasm`: the engine for the browser, compiled to WebAssembly,
|
||||||
|
- `tauri`: the desktop app, which runs the engine natively,
|
||||||
|
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application. It also is the UI of the desktop app.
|
||||||
|
|
||||||
### Building the `gpx` library
|
You will need [Node.js](https://nodejs.org/), [Rust](https://rustup.rs/) and [wasm-pack](https://rustwasm.github.io/wasm-pack/) to build and run the website. See [`tauri`](tauri/README.md) for the desktop app.
|
||||||
|
|
||||||
|
### Building the WebAssembly engine
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd gpx
|
cd wasm
|
||||||
npm install
|
wasm-pack build --target bundler
|
||||||
npm run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running the website
|
### Running the website
|
||||||
|
|
||||||
To be able to load the map, you will need to create your own <a href="https://account.mapbox.com/auth/signup" target="_blank">Mapbox access token</a> and store it in a `.env` file in the `website` directory.
|
To be able to load the map, you will need to create your own <a href="https://cloud.maptiler.com/auth/widget?next=https://cloud.maptiler.com/maps/" target="_blank">MapTiler key</a> and store it in a `.env` file in the `website` directory.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd website
|
cd website
|
||||||
echo PUBLIC_MAPBOX_TOKEN={YOUR_MAPBOX_TOKEN} >> .env
|
echo PUBLIC_MAPTILER_KEY={YOUR_MAPTILER_KEY} >> .env
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
@@ -57,21 +59,18 @@ 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:
|
||||||
- [immer](https://github.com/immerjs/immer) — complex state management
|
|
||||||
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
|
|
||||||
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
|
|
||||||
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
|
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
|
||||||
- Mapping:
|
- Mapping:
|
||||||
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
|
- [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive map rendering
|
||||||
- [brouter](https://github.com/abrensch/brouter) — routing engine
|
- [GraphHopper](https://github.com/graphhopper/graphhopper) — powerful routing engine
|
||||||
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
|
- [OpenStreetMap](https://www.openstreetmap.org) — open map data used by most of the map layers, and by the routing engine
|
||||||
|
- [Mapterhorn](https://github.com/mapterhorn/mapterhorn) — high-quality open terrain data used by some map layers (including for 3D), and by the routing engine
|
||||||
- Search:
|
- Search:
|
||||||
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "gpx-engine"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = "0.4.45"
|
||||||
|
postcard = { version = "1.1.3", features = ["use-std"] }
|
||||||
|
quick-xml = "0.42.0"
|
||||||
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
uuid = { version = "1.26.1", features = ["serde", "v4"] }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# gpx-rs
|
||||||
|
|
||||||
|
The GPX engine: plain Rust, no knowledge of who uses it. Tested natively with `cargo test` (from
|
||||||
|
here or from the root).
|
||||||
|
|
||||||
|
- `src/core`, `src/engine`, `src/storage`: the engine itself.
|
||||||
|
- `src/api`: the interface to the engine that the frontends (`../wasm`, `../tauri`) share. Each
|
||||||
|
function takes the engine and typed arguments, and gives plain data (`api::dto`, serde types, or
|
||||||
|
slices for the numeric buffers). A frontend only converts what crosses: ids from strings or
|
||||||
|
bytes, results to JS objects or JSON, slices to typed arrays or raw bytes.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="gpx.studio" xmlns="http://www.topografix.com/GPX/1/1"
|
||||||
|
xmlns:ext="https://example.com/ext/0.1">
|
||||||
|
<metadata>
|
||||||
|
<name>self closing points</name>
|
||||||
|
<desc>Points without children are self-closing elements.</desc>
|
||||||
|
<extensions>
|
||||||
|
<ext:version>2.0</ext:version>
|
||||||
|
<ext:paths/>
|
||||||
|
</extensions>
|
||||||
|
</metadata>
|
||||||
|
<wpt lat="50.0" lon="4.0"/>
|
||||||
|
<wpt lat="50.1" lon="4.1"><name>with children</name></wpt>
|
||||||
|
<wpt lat="50.2" lon="4.2"/>
|
||||||
|
<trk>
|
||||||
|
<name>track</name>
|
||||||
|
<trkseg>
|
||||||
|
<trkpt lat="50.00" lon="4.00"/>
|
||||||
|
<trkpt lat="50.01" lon="4.01"><ele>12.5</ele></trkpt>
|
||||||
|
<trkpt lat="50.02" lon="4.02"/>
|
||||||
|
</trkseg>
|
||||||
|
<trkseg>
|
||||||
|
<trkpt lat="51.00" lon="5.00"/>
|
||||||
|
</trkseg>
|
||||||
|
</trk>
|
||||||
|
</gpx>
|
||||||
@@ -1,18 +1,32 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
|
xmlns="http://www.topografix.com/GPX/1/1"
|
||||||
|
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd
|
||||||
|
http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd
|
||||||
|
http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd
|
||||||
|
http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
|
||||||
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
|
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
|
||||||
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
|
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
|
||||||
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
|
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1"
|
||||||
|
creator="https://gpx.studio">
|
||||||
<metadata>
|
<metadata>
|
||||||
<name>simple</name>
|
<name>simple</name>
|
||||||
|
<desc>description</desc>
|
||||||
<author>
|
<author>
|
||||||
<name>gpx.studio</name>
|
<name>gpx.studio</name>
|
||||||
<link href="https://gpx.studio"></link>
|
<link href="https://gpx.studio">
|
||||||
|
<text>link text</text>
|
||||||
|
</link>
|
||||||
</author>
|
</author>
|
||||||
</metadata>
|
</metadata>
|
||||||
<trk>
|
<trk>
|
||||||
<name>simple</name>
|
<name>track name</name>
|
||||||
|
<cmt>track comment</cmt>
|
||||||
|
<desc>track description</desc>
|
||||||
|
<src>track source</src>
|
||||||
|
<link href="https://gpx.studio">
|
||||||
|
<text>track link text</text>
|
||||||
|
</link>
|
||||||
<type>Cycling</type>
|
<type>Cycling</type>
|
||||||
<trkseg>
|
<trkseg>
|
||||||
<trkpt lat="50.790867" lon="4.404968">
|
<trkpt lat="50.790867" lon="4.404968">
|
||||||
File renamed without changes.
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx xmlns="http://www.topografix.com/GPX/1/1"
|
||||||
|
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" version="1.1" creator="https://gpx.studio">
|
||||||
|
<metadata>
|
||||||
|
<name>with_highway</name>
|
||||||
|
</metadata>
|
||||||
|
<trk>
|
||||||
|
<name>with_highway</name>
|
||||||
|
<trkseg>
|
||||||
|
<trkpt lat="50.790867" lon="4.404968">
|
||||||
|
<ele>109.0</ele>
|
||||||
|
<extensions>
|
||||||
|
<gpxtpx:TrackPointExtension>
|
||||||
|
<gpxtpx:Extensions>
|
||||||
|
<surface>asphalt</surface>
|
||||||
|
<highway>residential</highway>
|
||||||
|
</gpxtpx:Extensions>
|
||||||
|
</gpxtpx:TrackPointExtension>
|
||||||
|
</extensions>
|
||||||
|
</trkpt>
|
||||||
|
<trkpt lat="50.791000" lon="4.405100">
|
||||||
|
<ele>110.0</ele>
|
||||||
|
<extensions>
|
||||||
|
<gpxtpx:TrackPointExtension>
|
||||||
|
<gpxtpx:Extensions>
|
||||||
|
<surface>asphalt</surface>
|
||||||
|
<highway>residential</highway>
|
||||||
|
</gpxtpx:Extensions>
|
||||||
|
</gpxtpx:TrackPointExtension>
|
||||||
|
</extensions>
|
||||||
|
</trkpt>
|
||||||
|
<trkpt lat="50.791200" lon="4.405300">
|
||||||
|
<ele>111.0</ele>
|
||||||
|
</trkpt>
|
||||||
|
<trkpt lat="50.791400" lon="4.405500">
|
||||||
|
<ele>112.0</ele>
|
||||||
|
<extensions>
|
||||||
|
<gpxtpx:TrackPointExtension>
|
||||||
|
<gpxtpx:Extensions>
|
||||||
|
<surface>gravel</surface>
|
||||||
|
<highway>track</highway>
|
||||||
|
<sac_scale>mountain_hiking</sac_scale>
|
||||||
|
<mtb_scale>1</mtb_scale>
|
||||||
|
</gpxtpx:Extensions>
|
||||||
|
</gpxtpx:TrackPointExtension>
|
||||||
|
</extensions>
|
||||||
|
</trkpt>
|
||||||
|
<trkpt lat="50.791600" lon="4.405700">
|
||||||
|
<ele>113.0</ele>
|
||||||
|
<extensions>
|
||||||
|
<gpxtpx:TrackPointExtension>
|
||||||
|
<gpxtpx:Extensions>
|
||||||
|
<surface>gravel</surface>
|
||||||
|
<sac_scale>hiking</sac_scale>
|
||||||
|
<mtb_scale>1</mtb_scale>
|
||||||
|
</gpxtpx:Extensions>
|
||||||
|
</gpxtpx:TrackPointExtension>
|
||||||
|
</extensions>
|
||||||
|
</trkpt>
|
||||||
|
</trkseg>
|
||||||
|
</trk>
|
||||||
|
</gpx>
|
||||||
File renamed without changes.
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1" creator="test">
|
||||||
|
<metadata>
|
||||||
|
<name>with links</name>
|
||||||
|
<author>
|
||||||
|
<name>someone</name>
|
||||||
|
<email id="someone" domain="example.com"/>
|
||||||
|
<link href="https://example.com/author"><text>author</text></link>
|
||||||
|
</author>
|
||||||
|
<link href="https://example.com/file-1"><text>first</text></link>
|
||||||
|
<link href="https://example.com/file-2"/>
|
||||||
|
</metadata>
|
||||||
|
<wpt lat="50" lon="4">
|
||||||
|
<name>summit</name>
|
||||||
|
<link href="https://example.com/wpt-1"><text>one</text></link>
|
||||||
|
<link href="https://example.com/wpt-2"/>
|
||||||
|
<link href="https://example.com/wpt-3"><text>three</text></link>
|
||||||
|
</wpt>
|
||||||
|
<trk>
|
||||||
|
<name>track</name>
|
||||||
|
<link href="https://example.com/trk-1"/>
|
||||||
|
<link href="https://example.com/trk-2"><text>two</text></link>
|
||||||
|
<trkseg>
|
||||||
|
<trkpt lat="50" lon="4"/>
|
||||||
|
<trkpt lat="50.1" lon="4.1"/>
|
||||||
|
</trkseg>
|
||||||
|
</trk>
|
||||||
|
<rte>
|
||||||
|
<name>route</name>
|
||||||
|
<link href="https://example.com/rte-1"/>
|
||||||
|
<link href="https://example.com/rte-2"/>
|
||||||
|
<rtept lat="51" lon="5"/>
|
||||||
|
<rtept lat="51.1" lon="5.1"/>
|
||||||
|
</rte>
|
||||||
|
<wpt lat="51" lon="5">
|
||||||
|
<name>email variants</name>
|
||||||
|
</wpt>
|
||||||
|
</gpx>
|
||||||
File renamed without changes.
@@ -1,9 +1,14 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
|
xmlns="http://www.topografix.com/GPX/1/1"
|
||||||
|
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd
|
||||||
|
http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd
|
||||||
|
http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd
|
||||||
|
http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
|
||||||
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
|
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
|
||||||
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
|
xmlns:gpxpx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
|
||||||
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
|
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1"
|
||||||
|
creator="https://gpx.studio">
|
||||||
<metadata>
|
<metadata>
|
||||||
<name>with_power</name>
|
<name>with_power</name>
|
||||||
<author>
|
<author>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" version="1.1" creator="test">
|
||||||
|
<rte>
|
||||||
|
<name>with extensions</name>
|
||||||
|
<rtept lat="1" lon="1">
|
||||||
|
<ele>10</ele>
|
||||||
|
<extensions>
|
||||||
|
<gpxx:RoutePointExtension>
|
||||||
|
<gpxx:rpt lat="1.1" lon="1.1"/>
|
||||||
|
<gpxx:rpt lat="1.2" lon="1.2"></gpxx:rpt>
|
||||||
|
</gpxx:RoutePointExtension>
|
||||||
|
</extensions>
|
||||||
|
</rtept>
|
||||||
|
<rtept lat="2" lon="2">
|
||||||
|
<ele>20</ele>
|
||||||
|
<time>2024-01-01T00:00:00Z</time>
|
||||||
|
</rtept>
|
||||||
|
<rtept lat="3" lon="3"/>
|
||||||
|
</rte>
|
||||||
|
<rte>
|
||||||
|
<name>empty</name>
|
||||||
|
</rte>
|
||||||
|
</gpx>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" version="1.1" creator="test">
|
||||||
|
<rte>
|
||||||
|
<name>Lausanne</name>
|
||||||
|
<cmt>comment</cmt>
|
||||||
|
<desc>description</desc>
|
||||||
|
<type>Cycling</type>
|
||||||
|
<rtept lat="46.544628096744418" lon="6.658079791814089">
|
||||||
|
<ele>500</ele>
|
||||||
|
<extensions>
|
||||||
|
<gpxx:RoutePointExtension>
|
||||||
|
<gpxx:rpt lat="46.544483928009868" lon="6.658018603920937"/>
|
||||||
|
<gpxx:rpt lat="46.544415950775146" lon="6.658358573913574"/>
|
||||||
|
<gpxx:rpt lat="46.544415950775146" lon="6.658358573913574"/>
|
||||||
|
</gpxx:RoutePointExtension>
|
||||||
|
</extensions>
|
||||||
|
</rtept>
|
||||||
|
<rtept lat="46.544100000000000" lon="6.659000000000000">
|
||||||
|
<ele>510</ele>
|
||||||
|
<extensions>
|
||||||
|
<gpxx:RoutePointExtension>
|
||||||
|
<gpxx:rpt lat="46.543900000000000" lon="6.659500000000000"/>
|
||||||
|
</gpxx:RoutePointExtension>
|
||||||
|
</extensions>
|
||||||
|
</rtept>
|
||||||
|
<rtept lat="46.543000000000000" lon="6.660000000000000">
|
||||||
|
<ele>505</ele>
|
||||||
|
</rtept>
|
||||||
|
</rte>
|
||||||
|
</gpx>
|
||||||
File renamed without changes.
File renamed without changes.
@@ -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>
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,9 +1,14 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
|
xmlns="http://www.topografix.com/GPX/1/1"
|
||||||
|
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd
|
||||||
|
http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd
|
||||||
|
http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd
|
||||||
|
http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
|
||||||
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
|
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
|
||||||
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
|
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
|
||||||
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
|
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1"
|
||||||
|
creator="https://gpx.studio">
|
||||||
<metadata>
|
<metadata>
|
||||||
<name>with_waypoint</name>
|
<name>with_waypoint</name>
|
||||||
<author>
|
<author>
|
||||||
@@ -13,10 +18,14 @@
|
|||||||
</metadata>
|
</metadata>
|
||||||
<wpt lat="50.7836710064975" lon="4.410764082658738">
|
<wpt lat="50.7836710064975" lon="4.410764082658738">
|
||||||
<ele>122.0</ele>
|
<ele>122.0</ele>
|
||||||
<name>Waypoint</name>
|
<name>waypoint name</name>
|
||||||
<cmt>Comment</cmt>
|
<cmt>waypoint comment</cmt>
|
||||||
<desc>Description</desc>
|
<desc>waypoint description</desc>
|
||||||
|
<link href="https://gpx.studio">
|
||||||
|
<text>waypoint link text</text>
|
||||||
|
</link>
|
||||||
<sym>Bike Trail</sym>
|
<sym>Bike Trail</sym>
|
||||||
|
<type>Bike Trail</type>
|
||||||
</wpt>
|
</wpt>
|
||||||
<trk>
|
<trk>
|
||||||
<name>with_waypoint</name>
|
<name>with_waypoint</name>
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
//! The functions that edit the files or change the selection.
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::dto::*;
|
||||||
|
use crate::{self as engine, Action, Command, Engine, History, LngLat, LngLatBounds};
|
||||||
|
|
||||||
|
/// Executes an action and tells what it did.
|
||||||
|
pub fn execute<H: History>(engine: &mut Engine<H>, action: Action) -> Outcome {
|
||||||
|
match engine.execute(action) {
|
||||||
|
Ok(outcome) => Outcome::new(&outcome, None),
|
||||||
|
Err(error) => Outcome::failed(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit<H: History>(engine: &mut Engine<H>, command: Command) -> Outcome {
|
||||||
|
execute(engine, Action::Edit(command))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_ids(ids: &[Uuid]) -> Vec<engine::FileId> {
|
||||||
|
ids.iter().copied().map(engine::FileId).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
|
||||||
|
/// Selects files. Unknown files are ignored, and selecting nothing deselects everything.
|
||||||
|
pub fn select<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
file_ids_: &[Uuid],
|
||||||
|
mode: engine::SelectMode,
|
||||||
|
) -> Outcome {
|
||||||
|
select_elements(
|
||||||
|
engine,
|
||||||
|
engine::Selection::File {
|
||||||
|
file_ids: file_ids(file_ids_).into_iter().collect(),
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects elements: they are merged with the selection when it holds elements of the same kind in
|
||||||
|
/// the same place (same file, same track for segments), otherwise they replace it.
|
||||||
|
pub fn select_elements<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
selection: engine::Selection,
|
||||||
|
mode: engine::SelectMode,
|
||||||
|
) -> Outcome {
|
||||||
|
execute(engine, Action::Select { selection, mode })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects all the elements of the same kind as the selected ones, in the same place: all the
|
||||||
|
/// files, the tracks of the file, the segments of the track, the waypoints of the file. All the
|
||||||
|
/// files when nothing is selected.
|
||||||
|
pub fn select_all<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
|
execute(engine, Action::SelectAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the selection to the next (`down`) or previous element of the same kind, like the arrow
|
||||||
|
/// keys do in the file list. With `add` (shift + arrow), the element is added to the selection,
|
||||||
|
/// otherwise it replaces it.
|
||||||
|
pub fn arrow_select<H: History>(engine: &mut Engine<H>, down: bool, add: bool) -> Outcome {
|
||||||
|
execute(engine, Action::ArrowSelect { down, add })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_tracks<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
file_id: Uuid,
|
||||||
|
track_ids: &[Uuid],
|
||||||
|
mode: engine::SelectMode,
|
||||||
|
) -> Outcome {
|
||||||
|
let selection = engine::Selection::Track {
|
||||||
|
file_id: engine::FileId(file_id),
|
||||||
|
trk_ids: track_ids.iter().copied().map(engine::TrackId).collect(),
|
||||||
|
};
|
||||||
|
select_elements(engine, selection, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_segments<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
file_id: Uuid,
|
||||||
|
track_id: Uuid,
|
||||||
|
segment_ids: &[Uuid],
|
||||||
|
mode: engine::SelectMode,
|
||||||
|
) -> Outcome {
|
||||||
|
let selection = engine::Selection::TrackSegment {
|
||||||
|
file_id: engine::FileId(file_id),
|
||||||
|
trk_id: engine::TrackId(track_id),
|
||||||
|
trkseg_ids: segment_ids
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(engine::TrackSegmentId)
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
select_elements(engine, selection, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects the node standing for all the waypoints of a file.
|
||||||
|
pub fn select_waypoint_group<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
file_id: Uuid,
|
||||||
|
mode: engine::SelectMode,
|
||||||
|
) -> Outcome {
|
||||||
|
let selection = engine::Selection::Waypoints {
|
||||||
|
file_id: engine::FileId(file_id),
|
||||||
|
};
|
||||||
|
select_elements(engine, selection, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_waypoints<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
file_id: Uuid,
|
||||||
|
waypoint_ids: &[Uuid],
|
||||||
|
mode: engine::SelectMode,
|
||||||
|
) -> Outcome {
|
||||||
|
let selection = engine::Selection::Waypoint {
|
||||||
|
file_id: engine::FileId(file_id),
|
||||||
|
wpt_ids: waypoint_ids
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(engine::WaypointId)
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
select_elements(engine, selection, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// History
|
||||||
|
|
||||||
|
pub fn undo<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
|
execute(engine, Action::Undo)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redo<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
|
execute(engine, Action::Redo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files
|
||||||
|
|
||||||
|
/// Creates a file and selects it. With `lng` and `lat`, it starts with a track and a segment that
|
||||||
|
/// hold that trackpoint (at `ele`, 0 by default).
|
||||||
|
pub fn new_file<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
name: &str,
|
||||||
|
lng: Option<f64>,
|
||||||
|
lat: Option<f64>,
|
||||||
|
ele: Option<f64>,
|
||||||
|
) -> Outcome {
|
||||||
|
let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint {
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
ele: ele.unwrap_or_default(),
|
||||||
|
});
|
||||||
|
edit(engine, Command::New(engine::New { name, trackpoint }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads files as a single command (one undo step). The files are in `data`, one after the other,
|
||||||
|
/// `lengths` being the number of bytes of each one. `names`: the name of each file when its data
|
||||||
|
/// has none (the name on disk, without extension). The files that cannot be read are skipped, the
|
||||||
|
/// first one that was read is selected.
|
||||||
|
pub fn load_files<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
data: &[u8],
|
||||||
|
lengths: &[usize],
|
||||||
|
names: &[String],
|
||||||
|
) -> Outcome {
|
||||||
|
if lengths.len() != names.len() {
|
||||||
|
return Outcome::failed(format!(
|
||||||
|
"{} lengths for {} names",
|
||||||
|
lengths.len(),
|
||||||
|
names.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut files = Vec::with_capacity(names.len());
|
||||||
|
let mut offset = 0;
|
||||||
|
for (length, name) in lengths.iter().zip(names) {
|
||||||
|
let end = offset + *length;
|
||||||
|
let Some(data) = data.get(offset..end) else {
|
||||||
|
return Outcome::failed(format!("the data of {name} is shorter than its length"));
|
||||||
|
};
|
||||||
|
files.push(engine::Load { data, name });
|
||||||
|
offset = end;
|
||||||
|
}
|
||||||
|
edit(engine, Command::LoadFiles(engine::LoadFiles { files }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brings changes of the files from the outside in (see [`engine::SyncFiles`]): each file is
|
||||||
|
/// `(id, data, name)`, and replaces the file of that id, or is added if there is none. The files to
|
||||||
|
/// `remove` are deleted. The selection is left alone.
|
||||||
|
pub fn sync_files<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
files: &[(Uuid, &[u8], &str)],
|
||||||
|
remove: &[Uuid],
|
||||||
|
) -> Outcome {
|
||||||
|
let files = files
|
||||||
|
.iter()
|
||||||
|
.map(|(id, data, name)| engine::ExternalFile {
|
||||||
|
id: engine::FileId(*id),
|
||||||
|
data,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::SyncFiles(engine::SyncFiles {
|
||||||
|
files,
|
||||||
|
remove: file_ids(remove),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn duplicate<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
|
edit(engine, Command::Duplicate(engine::Duplicate))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements are
|
||||||
|
/// deleted instead, even if only a track or a waypoint is selected.
|
||||||
|
pub fn delete<H: History>(engine: &mut Engine<H>, whole_files: bool) -> Outcome {
|
||||||
|
edit(engine, Command::Delete(engine::Delete { whole_files }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_all<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
|
edit(engine, Command::DeleteAll(engine::DeleteAll))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the files to `index` among the other files, in the given order (not undoable).
|
||||||
|
pub fn reorder<H: History>(engine: &mut Engine<H>, file_ids_: &[Uuid], index: usize) -> Outcome {
|
||||||
|
execute(
|
||||||
|
engine,
|
||||||
|
Action::Reorder {
|
||||||
|
file_ids: file_ids(file_ids_),
|
||||||
|
index,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts the selected elements in the clipboard. Nothing happens when nothing is selected.
|
||||||
|
pub fn copy<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
|
execute(engine, Action::Copy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `copy`, but the elements are moved when they are pasted.
|
||||||
|
pub fn cut<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
|
execute(engine, Action::Cut)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pastes the clipboard according to the selection (see `can_paste`). What is pasted is always the
|
||||||
|
/// elements as they were when they were copied or cut, even if they were changed or deleted since.
|
||||||
|
/// It can be pasted several times: what was copied is copied again, what was cut is moved the first
|
||||||
|
/// time (and the clipboard is not cut after that), then it is copied.
|
||||||
|
pub fn paste<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
|
edit(engine, Command::Paste(engine::Paste))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves elements to a place of the file tree, keeping their ids, like dragging and dropping them
|
||||||
|
/// does. The moved elements are selected.
|
||||||
|
///
|
||||||
|
/// Files can only go among the files; tracks among the files (each becomes a file) or the tracks
|
||||||
|
/// of a file; segments among the files (each becomes a file), the tracks of a file (each becomes
|
||||||
|
/// a track) or the segments of a track; waypoints, or all the waypoints of a file, among the
|
||||||
|
/// waypoints of a file.
|
||||||
|
pub fn move_elements<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
what: Selection,
|
||||||
|
to: MoveTarget,
|
||||||
|
) -> Outcome {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::Move(engine::Move {
|
||||||
|
what: what.into(),
|
||||||
|
to: to.into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn metadata<H: History>(engine: &mut Engine<H>, name: &str, desc: &str) -> Outcome {
|
||||||
|
edit(engine, Command::Metadata(engine::Metadata { name, desc }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the given fields are changed.
|
||||||
|
pub fn style<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
color: Option<&str>,
|
||||||
|
opacity: Option<f64>,
|
||||||
|
width: Option<f64>,
|
||||||
|
) -> Outcome {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::Style(engine::Style {
|
||||||
|
color,
|
||||||
|
opacity,
|
||||||
|
width,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_track<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
|
edit(engine, Command::NewTrack(engine::NewTrack))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_track_segment<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
|
edit(engine, Command::NewTrackSegment(engine::NewTrackSegment))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waypoints. The strings that are empty are the fields that the waypoint does not have.
|
||||||
|
|
||||||
|
/// Adds a waypoint to each selected file (or to the file of the selected elements).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new_waypoint<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
lng: f64,
|
||||||
|
lat: f64,
|
||||||
|
ele: f64,
|
||||||
|
name: &str,
|
||||||
|
desc: &str,
|
||||||
|
icon: &str,
|
||||||
|
link: &str,
|
||||||
|
) -> Outcome {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::NewWaypoint(engine::NewWaypoint {
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
ele,
|
||||||
|
name,
|
||||||
|
desc,
|
||||||
|
icon,
|
||||||
|
link,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves a waypoint of a file, whatever is selected.
|
||||||
|
pub fn move_waypoint<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
file_id: Uuid,
|
||||||
|
waypoint_id: Uuid,
|
||||||
|
lng: f64,
|
||||||
|
lat: f64,
|
||||||
|
ele: f64,
|
||||||
|
) -> Outcome {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::MoveWaypoint(engine::MoveWaypoint {
|
||||||
|
file_id: engine::FileId(file_id),
|
||||||
|
waypoint_id: engine::WaypointId(waypoint_id),
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
ele,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a waypoint of a file, whatever is selected.
|
||||||
|
pub fn delete_waypoint<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
file_id: Uuid,
|
||||||
|
waypoint_id: Uuid,
|
||||||
|
) -> Outcome {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::DeleteWaypoint(engine::DeleteWaypoint {
|
||||||
|
file_id: engine::FileId(file_id),
|
||||||
|
waypoint_id: engine::WaypointId(waypoint_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes a waypoint of a file, whatever is selected: its name, description, icon, link,
|
||||||
|
/// position and elevation.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn update_waypoint<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
file_id: Uuid,
|
||||||
|
waypoint_id: Uuid,
|
||||||
|
lng: f64,
|
||||||
|
lat: f64,
|
||||||
|
ele: f64,
|
||||||
|
name: &str,
|
||||||
|
desc: &str,
|
||||||
|
icon: &str,
|
||||||
|
link: &str,
|
||||||
|
) -> Outcome {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::EditWaypoint(engine::EditWaypoint {
|
||||||
|
file_id: engine::FileId(file_id),
|
||||||
|
waypoint_id: engine::WaypointId(waypoint_id),
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
ele,
|
||||||
|
name,
|
||||||
|
desc,
|
||||||
|
icon,
|
||||||
|
link,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
|
||||||
|
pub fn reverse<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
|
edit(engine, Command::Reverse(engine::Reverse))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds to each selected segment a reversed copy of itself (without repeating its last point), so
|
||||||
|
/// that it goes back to where it started.
|
||||||
|
pub fn round_trip<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
|
edit(engine, Command::RoundTrip(engine::RoundTrip))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the trackpoints `start` to `end` (both included) of the selection, counted over its
|
||||||
|
/// segments one after the other, and removes the others (and what is left empty).
|
||||||
|
pub fn crop<H: History>(engine: &mut Engine<H>, start: u32, end: u32) -> Outcome {
|
||||||
|
if start > end {
|
||||||
|
return Outcome::failed(format!("the range {start}..{end} is reversed"));
|
||||||
|
}
|
||||||
|
edit(engine, Command::Crop(engine::Crop { start, end }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the trackpoints of the selection that are less than `tolerance` meters away from the
|
||||||
|
/// line of their neighbours, except the ends of the segments.
|
||||||
|
pub fn reduce<H: History>(engine: &mut Engine<H>, tolerance: f64) -> Outcome {
|
||||||
|
edit(engine, Command::Reduce(engine::Reduce { tolerance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the timestamps of the selection, which starts at `start_time` (ms since the epoch): the
|
||||||
|
/// trackpoints that have some keep their durations multiplied by `ratio`, the others follow the
|
||||||
|
/// previous one at `speed` (km/h).
|
||||||
|
pub fn change_timestamps<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
start_time: f64,
|
||||||
|
speed: f64,
|
||||||
|
ratio: f64,
|
||||||
|
) -> Outcome {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::Time(engine::Time {
|
||||||
|
start_time: start_time as i64,
|
||||||
|
kind: engine::TimeKind::Change { speed, ratio },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes up timestamps for the selection, which starts at `start_time` (ms since the epoch) and
|
||||||
|
/// lasts `total_time` seconds.
|
||||||
|
pub fn create_artificial_timestamps<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
start_time: f64,
|
||||||
|
total_time: f64,
|
||||||
|
) -> Outcome {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::Time(engine::Time {
|
||||||
|
start_time: start_time as i64,
|
||||||
|
kind: engine::TimeKind::Artificial { total_time },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges the selection into its first element. `remove_gaps` brings the parts that are connected
|
||||||
|
/// closer in time, if they have timestamps. Merged files are kept (and the result is a new file) if
|
||||||
|
/// the history needs it, see [`History::KEEPS_ORIGINALS`].
|
||||||
|
pub fn merge<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
type_: engine::MergeType,
|
||||||
|
remove_gaps: bool,
|
||||||
|
) -> Outcome {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::Merge(engine::Merge {
|
||||||
|
type_,
|
||||||
|
remove_gaps,
|
||||||
|
keep_originals: H::KEEPS_ORIGINALS,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracted files are kept (and the result is new files) if the history needs it, see
|
||||||
|
/// [`History::KEEPS_ORIGINALS`].
|
||||||
|
pub fn extract<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::Extract(engine::Extract {
|
||||||
|
keep_originals: H::KEEPS_ORIGINALS,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the elevation of the trackpoints of the selection, one elevation per trackpoint.
|
||||||
|
pub fn elevation<H: History>(engine: &mut Engine<H>, ele: &[f64]) -> Outcome {
|
||||||
|
edit(engine, Command::Elevation(engine::Elevation { ele }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the trackpoints and/or waypoints of the selection inside (or outside) a rectangle.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn clean<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
west: f64,
|
||||||
|
south: f64,
|
||||||
|
east: f64,
|
||||||
|
north: f64,
|
||||||
|
type_: engine::CleanType,
|
||||||
|
trkpt: bool,
|
||||||
|
wpt: bool,
|
||||||
|
) -> Outcome {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::Clean(engine::Clean {
|
||||||
|
bounds: LngLatBounds {
|
||||||
|
sw: LngLat {
|
||||||
|
lng: west,
|
||||||
|
lat: south,
|
||||||
|
},
|
||||||
|
ne: LngLat {
|
||||||
|
lng: east,
|
||||||
|
lat: north,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type_,
|
||||||
|
trkpt,
|
||||||
|
wpt,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routing
|
||||||
|
//
|
||||||
|
// The indices of the trackpoints and of the anchors are the ones of the routing buffers: they only
|
||||||
|
// mean something for the `revision` they were read with (`routing_revision`), and the commands
|
||||||
|
// that use them do nothing if the selection is not the one of that revision anymore.
|
||||||
|
|
||||||
|
/// Makes the call `f`, unless the selection is not the one of the revision anymore.
|
||||||
|
fn at_revision<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
revision: u32,
|
||||||
|
f: impl FnOnce(&mut Engine<H>) -> Outcome,
|
||||||
|
) -> Outcome {
|
||||||
|
if engine.routing().revision == revision {
|
||||||
|
f(engine)
|
||||||
|
} else {
|
||||||
|
Outcome::failed("the selection changed since the routing buffers were read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the trackpoints `start..end` of the selection by the given ones, in a single segment
|
||||||
|
/// (a pure insertion if `start == end`, a pure removal without new points). `attributes` holds the
|
||||||
|
/// surface, highway, SAC scale and MTB scale of the new points, as intervals, and `anchors` the
|
||||||
|
/// indices among the new points of the ones that become anchors.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn route<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
revision: u32,
|
||||||
|
start: u32,
|
||||||
|
end: u32,
|
||||||
|
lng: &[f64],
|
||||||
|
lat: &[f64],
|
||||||
|
ele: &[f64],
|
||||||
|
attributes: &RouteAttributes,
|
||||||
|
anchors: &[u32],
|
||||||
|
) -> Outcome {
|
||||||
|
if lng.len() != lat.len() || lat.len() != ele.len() {
|
||||||
|
return Outcome::failed("lng, lat and ele differ in length");
|
||||||
|
}
|
||||||
|
at_revision(engine, revision, |engine| {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::Route(engine::Route {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
ele,
|
||||||
|
surface: attributes.surface.view(),
|
||||||
|
highway: attributes.highway.view(),
|
||||||
|
sac_scale: attributes.sac_scale.view(),
|
||||||
|
mtb_scale: attributes.mtb_scale.view(),
|
||||||
|
anchors,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes an anchor of the point of the selected segments that is the closest to the coordinates,
|
||||||
|
/// inserting a trackpoint on the path if none is there.
|
||||||
|
pub fn insert_anchor<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
revision: u32,
|
||||||
|
lng: f64,
|
||||||
|
lat: f64,
|
||||||
|
) -> Outcome {
|
||||||
|
at_revision(engine, revision, |engine| {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::InsertAnchor(engine::InsertAnchor { lng, lat }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes the trackpoint `index` of the selection the start of its segment, which is a loop (the
|
||||||
|
/// trackpoints before it go to the end, and the loop closes on it).
|
||||||
|
pub fn change_loop_start<H: History>(engine: &mut Engine<H>, revision: u32, index: u32) -> Outcome {
|
||||||
|
at_revision(engine, revision, |engine| {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::ChangeLoopStart(engine::ChangeLoopStart { index }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the
|
||||||
|
/// selection in two, there: the trackpoint ends the first part and starts the second one. A file that
|
||||||
|
/// is cut is kept (and its two parts are new files) if the history needs it, see
|
||||||
|
/// [`History::KEEPS_ORIGINALS`].
|
||||||
|
pub fn split<H: History>(
|
||||||
|
engine: &mut Engine<H>,
|
||||||
|
revision: u32,
|
||||||
|
at: u32,
|
||||||
|
split_type: engine::SplitType,
|
||||||
|
) -> Outcome {
|
||||||
|
at_revision(engine, revision, |engine| {
|
||||||
|
edit(
|
||||||
|
engine,
|
||||||
|
Command::Split(engine::Split {
|
||||||
|
at,
|
||||||
|
split_type,
|
||||||
|
keep_originals: H::KEEPS_ORIGINALS,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
//! What crosses to the frontend, as plain data (serde): the shapes of the objects that the frontend
|
||||||
|
//! reads, which `wasm/src/ts.rs` declares in TypeScript. Ids are hyphenated UUID strings, times
|
||||||
|
//! are numbers (ms since the epoch), absent fields are skipped.
|
||||||
|
|
||||||
|
use crate as engine;
|
||||||
|
use engine::{FileId, History};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub fn ids(items: &[FileId]) -> Vec<String> {
|
||||||
|
items.iter().map(|id| id.0.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outcome
|
||||||
|
|
||||||
|
/// What a call that edits the files or changes the selection did.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Outcome {
|
||||||
|
pub changed: bool,
|
||||||
|
order_changed: bool,
|
||||||
|
selection_changed: bool,
|
||||||
|
clipboard_changed: bool,
|
||||||
|
added: Vec<String>,
|
||||||
|
modified: Vec<String>,
|
||||||
|
removed: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
/// Whether the files changed in a way that has to be saved (not sent).
|
||||||
|
#[serde(skip)]
|
||||||
|
pub saves: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
pub fn new(outcome: &engine::Outcome, error: Option<String>) -> Self {
|
||||||
|
let diff = outcome.diff.clone().unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
changed: outcome.changed,
|
||||||
|
order_changed: outcome.order_changed,
|
||||||
|
selection_changed: outcome.selection_changed,
|
||||||
|
clipboard_changed: outcome.clipboard_changed,
|
||||||
|
added: ids(&diff.added),
|
||||||
|
modified: ids(&diff.modified),
|
||||||
|
removed: ids(&diff.removed),
|
||||||
|
error,
|
||||||
|
saves: outcome.changed && (outcome.diff.is_some() || outcome.order_changed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of a frontend that knows nothing of the engine: all the files are added, and the
|
||||||
|
/// order, the selection and the clipboard are to be read.
|
||||||
|
pub fn everything<H: History>(engine: &engine::Engine<H>) -> Self {
|
||||||
|
Self {
|
||||||
|
changed: true,
|
||||||
|
order_changed: true,
|
||||||
|
selection_changed: true,
|
||||||
|
clipboard_changed: true,
|
||||||
|
added: ids(engine.order()),
|
||||||
|
modified: vec![],
|
||||||
|
removed: vec![],
|
||||||
|
error: None,
|
||||||
|
saves: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of a call that did nothing and has nothing to say.
|
||||||
|
pub fn failed_none() -> Self {
|
||||||
|
Self::new(&engine::Outcome::default(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the call did nothing and says why.
|
||||||
|
pub fn has_error(&self) -> bool {
|
||||||
|
self.error.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What two calls that were made one after the other did together.
|
||||||
|
pub fn merge(mut self, other: Self) -> Self {
|
||||||
|
fn union(into: &mut Vec<String>, from: Vec<String>) {
|
||||||
|
for id in from {
|
||||||
|
if !into.contains(&id) {
|
||||||
|
into.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.changed |= other.changed;
|
||||||
|
self.order_changed |= other.order_changed;
|
||||||
|
self.selection_changed |= other.selection_changed;
|
||||||
|
self.clipboard_changed |= other.clipboard_changed;
|
||||||
|
self.saves |= other.saves;
|
||||||
|
// a file that was added and then modified is still an added one, and one that was added
|
||||||
|
// and then removed is not there at all
|
||||||
|
let added_before = self.added.clone();
|
||||||
|
union(&mut self.added, other.added);
|
||||||
|
for id in other.modified {
|
||||||
|
if !self.added.contains(&id) && !self.modified.contains(&id) {
|
||||||
|
self.modified.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in other.removed {
|
||||||
|
self.added.retain(|added| *added != id);
|
||||||
|
self.modified.retain(|modified| *modified != id);
|
||||||
|
if !added_before.contains(&id) && !self.removed.contains(&id) {
|
||||||
|
self.removed.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.error = self.error.or(other.error);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a call that does nothing returns.
|
||||||
|
pub fn failed(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(&engine::Outcome::default(), Some(message.into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection and clipboard
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase"
|
||||||
|
)]
|
||||||
|
pub enum Selection {
|
||||||
|
Empty,
|
||||||
|
File {
|
||||||
|
file_ids: Vec<Uuid>,
|
||||||
|
},
|
||||||
|
Track {
|
||||||
|
file_id: Uuid,
|
||||||
|
track_ids: Vec<Uuid>,
|
||||||
|
},
|
||||||
|
Segment {
|
||||||
|
file_id: Uuid,
|
||||||
|
track_id: Uuid,
|
||||||
|
segment_ids: Vec<Uuid>,
|
||||||
|
},
|
||||||
|
Waypoints {
|
||||||
|
file_id: Uuid,
|
||||||
|
},
|
||||||
|
Waypoint {
|
||||||
|
file_id: Uuid,
|
||||||
|
waypoint_ids: Vec<Uuid>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&engine::Selection> for Selection {
|
||||||
|
fn from(selection: &engine::Selection) -> Self {
|
||||||
|
use engine::Selection as S;
|
||||||
|
match selection {
|
||||||
|
S::Empty => Self::Empty,
|
||||||
|
S::File { file_ids } => Self::File {
|
||||||
|
file_ids: file_ids.iter().map(|id| id.0).collect(),
|
||||||
|
},
|
||||||
|
S::Track { file_id, trk_ids } => Self::Track {
|
||||||
|
file_id: file_id.0,
|
||||||
|
track_ids: trk_ids.iter().map(|id| id.0).collect(),
|
||||||
|
},
|
||||||
|
S::TrackSegment {
|
||||||
|
file_id,
|
||||||
|
trk_id,
|
||||||
|
trkseg_ids,
|
||||||
|
} => Self::Segment {
|
||||||
|
file_id: file_id.0,
|
||||||
|
track_id: trk_id.0,
|
||||||
|
segment_ids: trkseg_ids.iter().map(|id| id.0).collect(),
|
||||||
|
},
|
||||||
|
S::Waypoints { file_id } => Self::Waypoints { file_id: file_id.0 },
|
||||||
|
S::Waypoint { file_id, wpt_ids } => Self::Waypoint {
|
||||||
|
file_id: file_id.0,
|
||||||
|
waypoint_ids: wpt_ids.iter().map(|id| id.0).collect(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Selection> for engine::Selection {
|
||||||
|
fn from(selection: Selection) -> Self {
|
||||||
|
use engine::Selection as S;
|
||||||
|
match selection {
|
||||||
|
Selection::Empty => S::Empty,
|
||||||
|
Selection::File { file_ids } => S::File {
|
||||||
|
file_ids: file_ids.into_iter().map(FileId).collect(),
|
||||||
|
},
|
||||||
|
Selection::Track { file_id, track_ids } => S::Track {
|
||||||
|
file_id: FileId(file_id),
|
||||||
|
trk_ids: track_ids.into_iter().map(engine::TrackId).collect(),
|
||||||
|
},
|
||||||
|
Selection::Segment {
|
||||||
|
file_id,
|
||||||
|
track_id,
|
||||||
|
segment_ids,
|
||||||
|
} => S::TrackSegment {
|
||||||
|
file_id: FileId(file_id),
|
||||||
|
trk_id: engine::TrackId(track_id),
|
||||||
|
trkseg_ids: segment_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(engine::TrackSegmentId)
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
Selection::Waypoints { file_id } => S::Waypoints {
|
||||||
|
file_id: FileId(file_id),
|
||||||
|
},
|
||||||
|
Selection::Waypoint {
|
||||||
|
file_id,
|
||||||
|
waypoint_ids,
|
||||||
|
} => S::Waypoint {
|
||||||
|
file_id: FileId(file_id),
|
||||||
|
wpt_ids: waypoint_ids.into_iter().map(engine::WaypointId).collect(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where moved elements go. A negative index is the start, an infinite one the end (which JSON
|
||||||
|
/// writes as `null`, so the frontend sends a very large number instead).
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase"
|
||||||
|
)]
|
||||||
|
pub enum MoveTarget {
|
||||||
|
Files {
|
||||||
|
index: f64,
|
||||||
|
},
|
||||||
|
Tracks {
|
||||||
|
file_id: Uuid,
|
||||||
|
index: f64,
|
||||||
|
},
|
||||||
|
Segments {
|
||||||
|
file_id: Uuid,
|
||||||
|
track_id: Uuid,
|
||||||
|
index: f64,
|
||||||
|
},
|
||||||
|
Waypoints {
|
||||||
|
file_id: Uuid,
|
||||||
|
index: f64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MoveTarget> for engine::MoveTarget {
|
||||||
|
fn from(target: MoveTarget) -> Self {
|
||||||
|
use engine::MoveTarget as T;
|
||||||
|
let index = |index: f64| index.max(0.0) as usize;
|
||||||
|
match target {
|
||||||
|
MoveTarget::Files { index: i } => T::Files { index: index(i) },
|
||||||
|
MoveTarget::Tracks { file_id, index: i } => T::Tracks {
|
||||||
|
file_id: FileId(file_id),
|
||||||
|
index: index(i),
|
||||||
|
},
|
||||||
|
MoveTarget::Segments {
|
||||||
|
file_id,
|
||||||
|
track_id,
|
||||||
|
index: i,
|
||||||
|
} => T::Segments {
|
||||||
|
file_id: FileId(file_id),
|
||||||
|
trk_id: engine::TrackId(track_id),
|
||||||
|
index: index(i),
|
||||||
|
},
|
||||||
|
MoveTarget::Waypoints { file_id, index: i } => T::Waypoints {
|
||||||
|
file_id: FileId(file_id),
|
||||||
|
index: index(i),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Clipboard {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: &'static str,
|
||||||
|
ids: Vec<String>,
|
||||||
|
cut: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clipboard {
|
||||||
|
pub fn new(clipboard: &engine::Clipboard) -> Self {
|
||||||
|
let strings = |ids: Vec<Uuid>| ids.iter().map(Uuid::to_string).collect();
|
||||||
|
let (kind, ids) = match clipboard.content.ids() {
|
||||||
|
engine::ClipboardIds::Files(ids) => {
|
||||||
|
("files", strings(ids.into_iter().map(|id| id.0).collect()))
|
||||||
|
}
|
||||||
|
engine::ClipboardIds::Tracks(ids) => {
|
||||||
|
("tracks", strings(ids.into_iter().map(|id| id.0).collect()))
|
||||||
|
}
|
||||||
|
engine::ClipboardIds::Segments(ids) => (
|
||||||
|
"segments",
|
||||||
|
strings(ids.into_iter().map(|id| id.0).collect()),
|
||||||
|
),
|
||||||
|
engine::ClipboardIds::Waypoints(ids) => (
|
||||||
|
"waypoints",
|
||||||
|
strings(ids.into_iter().map(|id| id.0).collect()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
ids,
|
||||||
|
cut: clipboard.cut,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileStructure {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
desc: Option<String>,
|
||||||
|
tracks: Vec<TrackNode>,
|
||||||
|
waypoints: Vec<WaypointNode>,
|
||||||
|
waypoints_rev: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct TrackNode {
|
||||||
|
id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
desc: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
color: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
opacity: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
width: Option<f64>,
|
||||||
|
segments: Vec<SegmentNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SegmentNode {
|
||||||
|
id: String,
|
||||||
|
rev: String,
|
||||||
|
length: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct WaypointNode {
|
||||||
|
id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
sym: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileStructure {
|
||||||
|
pub fn new(file: &engine::FileStructure) -> Self {
|
||||||
|
Self {
|
||||||
|
id: file.id.0.to_string(),
|
||||||
|
name: file.name.clone(),
|
||||||
|
desc: file.desc.clone(),
|
||||||
|
tracks: file
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.map(|trk| TrackNode {
|
||||||
|
id: trk.id.0.to_string(),
|
||||||
|
name: trk.name.clone(),
|
||||||
|
desc: trk.desc.clone(),
|
||||||
|
color: trk.color.clone(),
|
||||||
|
opacity: trk.opacity,
|
||||||
|
width: trk.width,
|
||||||
|
segments: trk
|
||||||
|
.segments
|
||||||
|
.iter()
|
||||||
|
.map(|seg| SegmentNode {
|
||||||
|
id: seg.id.0.to_string(),
|
||||||
|
rev: seg.rev_id.0.to_string(),
|
||||||
|
length: seg.len as f64,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
waypoints: file
|
||||||
|
.waypoints
|
||||||
|
.iter()
|
||||||
|
.map(|wpt| WaypointNode {
|
||||||
|
id: wpt.id.0.to_string(),
|
||||||
|
name: wpt.name.clone(),
|
||||||
|
sym: wpt.sym.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
waypoints_rev: file.wpt_rev_id.0.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GlobalStatistics {
|
||||||
|
total_distance: f64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
moving_distance: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
total_time: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
moving_time: Option<f64>,
|
||||||
|
elevation_gain: f64,
|
||||||
|
elevation_loss: f64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
start_time: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
end_time: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
total_speed: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
moving_speed: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
bounds: Option<Bounds>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Bounds {
|
||||||
|
west: f64,
|
||||||
|
south: f64,
|
||||||
|
east: f64,
|
||||||
|
north: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GlobalStatistics {
|
||||||
|
pub fn new(stats: &engine::GlobalStatistics) -> Self {
|
||||||
|
let seconds = |ms: Option<i64>| ms.map(|ms| ms as f64 / 1000.0);
|
||||||
|
let (sw, ne) = (&stats.bounds.sw, &stats.bounds.ne);
|
||||||
|
Self {
|
||||||
|
total_distance: stats.total_distance,
|
||||||
|
moving_distance: stats.moving_distance,
|
||||||
|
total_time: seconds(stats.total_time),
|
||||||
|
moving_time: seconds(stats.moving_time),
|
||||||
|
elevation_gain: stats.elevation_gain,
|
||||||
|
elevation_loss: stats.elevation_loss,
|
||||||
|
start_time: stats.start_time.map(|t| t as f64),
|
||||||
|
end_time: stats.end_time.map(|t| t as f64),
|
||||||
|
total_speed: stats.total_speed(),
|
||||||
|
moving_speed: stats.moving_speed(),
|
||||||
|
bounds: (sw.lng <= ne.lng && sw.lat <= ne.lat).then_some(Bounds {
|
||||||
|
west: sw.lng,
|
||||||
|
south: sw.lat,
|
||||||
|
east: ne.lng,
|
||||||
|
north: ne.lat,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct WaypointDetails {
|
||||||
|
id: String,
|
||||||
|
lng: f64,
|
||||||
|
lat: f64,
|
||||||
|
ele: f64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
time: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
desc: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
cmt: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
sym: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
links: Option<Vec<Link>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Link {
|
||||||
|
href: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WaypointDetails {
|
||||||
|
pub fn new(wpt: &engine::Waypoint) -> Self {
|
||||||
|
Self {
|
||||||
|
id: wpt.id.0.to_string(),
|
||||||
|
lng: wpt.coordinates.lng,
|
||||||
|
lat: wpt.coordinates.lat,
|
||||||
|
ele: wpt.ele,
|
||||||
|
time: wpt.time.map(|t| t as f64),
|
||||||
|
name: wpt.name.clone(),
|
||||||
|
desc: wpt.desc.clone(),
|
||||||
|
cmt: wpt.cmt.clone(),
|
||||||
|
sym: wpt.sym.clone(),
|
||||||
|
links: (!wpt.links.is_empty()).then(|| {
|
||||||
|
wpt.links
|
||||||
|
.iter()
|
||||||
|
.map(|link| Link {
|
||||||
|
href: link.href.clone(),
|
||||||
|
text: link.text.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TrackpointDetails {
|
||||||
|
lng: f64,
|
||||||
|
lat: f64,
|
||||||
|
ele: f64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
time: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackpointDetails {
|
||||||
|
pub fn new(pt: &engine::Trackpoint) -> Self {
|
||||||
|
Self {
|
||||||
|
lng: pt.coordinates.lng,
|
||||||
|
lat: pt.coordinates.lat,
|
||||||
|
ele: pt.ele,
|
||||||
|
time: pt.time.map(|t| t as f64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routing
|
||||||
|
|
||||||
|
/// A category of the trackpoints of a route, as in `RouteCategory` of the engine.
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
pub struct RouteCategory {
|
||||||
|
pub starts: Vec<u32>,
|
||||||
|
pub values: Vec<u8>,
|
||||||
|
pub names: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RouteCategory {
|
||||||
|
pub fn view(&self) -> engine::RouteCategory<'_> {
|
||||||
|
engine::RouteCategory {
|
||||||
|
starts: &self.starts,
|
||||||
|
values: &self.values,
|
||||||
|
names: &self.names,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct RouteAttributes {
|
||||||
|
pub surface: RouteCategory,
|
||||||
|
pub highway: RouteCategory,
|
||||||
|
pub sac_scale: RouteCategory,
|
||||||
|
pub mtb_scale: RouteCategory,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//! The interface to the engine, which the interfaces to the frontend share: the web page
|
||||||
|
//! (`wasm`) and the desktop app (`tauri`) each expose these functions, and only convert what
|
||||||
|
//! crosses to their own way of passing data.
|
||||||
|
//!
|
||||||
|
//! - `commands`: the functions that edit the files or change the selection. Each returns an
|
||||||
|
//! `Outcome`: whether anything changed, what changed (to update what the frontend keeps), and why
|
||||||
|
//! nothing was done when that is so (the command had nothing to do, the data is invalid, an
|
||||||
|
//! argument is wrong).
|
||||||
|
//! - `queries`: the functions that read the state of the engine.
|
||||||
|
//! - `dto`: the data that crosses, as serde types.
|
||||||
|
//!
|
||||||
|
//! Ids are `Uuid`s: how the frontend sends them (strings, bytes) is for the interfaces to parse.
|
||||||
|
//! Coordinates and statistics are slices of numbers, which the interfaces copy to typed arrays.
|
||||||
|
|
||||||
|
pub mod commands;
|
||||||
|
pub mod dto;
|
||||||
|
pub mod queries;
|
||||||
|
|
||||||
|
pub use commands::*;
|
||||||
|
pub use dto::*;
|
||||||
|
pub use queries::*;
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
//! The functions that read the state of the engine.
|
||||||
|
//!
|
||||||
|
//! The files are described by one ordered list of ids and one structure per file, so that the UI
|
||||||
|
//! can react to what changed only: after each command, its `Outcome` tells which structures to
|
||||||
|
//! (re)read or drop and whether the order changed. The trackpoints of a segment and the waypoints
|
||||||
|
//! of a file are in the same order as the coordinates of their buffers.
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::dto::*;
|
||||||
|
use crate::{self as engine, Engine, FileId, History};
|
||||||
|
|
||||||
|
/// Ids of the files in display order.
|
||||||
|
pub fn file_order<H: History>(engine: &Engine<H>) -> Vec<String> {
|
||||||
|
ids(engine.order())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structure of a file, `None` if the id is unknown.
|
||||||
|
pub fn file_structure<H: History>(engine: &Engine<H>, file_id: Uuid) -> Option<FileStructure> {
|
||||||
|
engine
|
||||||
|
.file_structure(&FileId(file_id))
|
||||||
|
.map(FileStructure::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global statistics of a file, `None` if the id is unknown.
|
||||||
|
pub fn file_statistics<H: History>(engine: &Engine<H>, file_id: Uuid) -> Option<GlobalStatistics> {
|
||||||
|
engine
|
||||||
|
.file_statistics(&FileId(file_id))
|
||||||
|
.map(|stats| GlobalStatistics::new(&stats))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current selection.
|
||||||
|
pub fn selection<H: History>(engine: &Engine<H>) -> Selection {
|
||||||
|
engine.selection().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What was copied or cut and is waiting to be pasted.
|
||||||
|
pub fn clipboard<H: History>(engine: &Engine<H>) -> Option<Clipboard> {
|
||||||
|
engine.clipboard().map(Clipboard::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_undo<H: History>(engine: &Engine<H>) -> bool {
|
||||||
|
engine.can_undo()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_redo<H: History>(engine: &Engine<H>) -> bool {
|
||||||
|
engine.can_redo()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the clipboard can be pasted with the current selection.
|
||||||
|
pub fn can_paste<H: History>(engine: &Engine<H>) -> bool {
|
||||||
|
engine.can_paste()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All the data of a waypoint of a file, `None` if it does not exist.
|
||||||
|
pub fn waypoint<H: History>(
|
||||||
|
engine: &Engine<H>,
|
||||||
|
file_id: Uuid,
|
||||||
|
waypoint_id: Uuid,
|
||||||
|
) -> Option<WaypointDetails> {
|
||||||
|
engine
|
||||||
|
.waypoint(&FileId(file_id), &engine::WaypointId(waypoint_id))
|
||||||
|
.map(WaypointDetails::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trackpoint of a segment of a file, `None` if it does not exist.
|
||||||
|
pub fn trackpoint<H: History>(
|
||||||
|
engine: &Engine<H>,
|
||||||
|
file_id: Uuid,
|
||||||
|
segment_id: Uuid,
|
||||||
|
index: usize,
|
||||||
|
) -> Option<TrackpointDetails> {
|
||||||
|
engine
|
||||||
|
.trackpoint(&FileId(file_id), &engine::TrackSegmentId(segment_id), index)
|
||||||
|
.map(TrackpointDetails::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file as GPX (UTF-8 bytes), `None` if it does not exist. The options say which data of the
|
||||||
|
/// trackpoints to keep, and with `as_route` that the segments are written as routes (`rte`)
|
||||||
|
/// instead of tracks: the anchors are the route points, and the trackpoints between them the
|
||||||
|
/// points of their path.
|
||||||
|
pub fn export_file<H: History>(
|
||||||
|
engine: &Engine<H>,
|
||||||
|
file_id: Uuid,
|
||||||
|
options: engine::ExportOptions,
|
||||||
|
) -> Option<Vec<u8>> {
|
||||||
|
engine.export(&FileId(file_id), options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The data that some files have, so that the ones that none has are not offered when exporting
|
||||||
|
/// them: a bit for the time (1), heart rate (2), cadence (4), temperature (8), power (16) and the
|
||||||
|
/// OpenStreetMap data (32).
|
||||||
|
pub fn exportable_data<H: History>(engine: &Engine<H>, file_ids: &[Uuid]) -> u8 {
|
||||||
|
let ids: Vec<FileId> = file_ids.iter().copied().map(FileId).collect();
|
||||||
|
let data = engine.exportable_data(&ids);
|
||||||
|
[
|
||||||
|
data.time, data.hr, data.cad, data.atemp, data.power, data.osm,
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.fold(0, |bits, (i, on)| bits | (u8::from(*on) << i))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For each trackpoint of the selection (numbered as in the statistics), the highest tolerance
|
||||||
|
/// (m) of `reduce` that keeps it: infinite for the ends of the segments.
|
||||||
|
pub fn reduction_distances<H: History>(engine: &Engine<H>) -> Vec<f64> {
|
||||||
|
engine.reduction_distances()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coordinates, as flat `[lng, lat, ...]` slices. Compare `rev` / `waypointsRev` of the file
|
||||||
|
// structures with the previous ones to know which ones actually changed.
|
||||||
|
|
||||||
|
/// Coordinates of the trackpoints of a segment (empty if the id is unknown).
|
||||||
|
pub fn segment_coordinates<H: History>(engine: &Engine<H>, segment_id: Uuid) -> &[f64] {
|
||||||
|
engine.segment_coordinates(&engine::TrackSegmentId(segment_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coordinates of the waypoints of a file (empty if the id is unknown).
|
||||||
|
pub fn waypoint_coordinates<H: History>(engine: &Engine<H>, file_id: Uuid) -> &[f64] {
|
||||||
|
engine.waypoint_coordinates(&FileId(file_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistics of the selection
|
||||||
|
|
||||||
|
/// Global statistics of the whole selection (the buffers below cover it).
|
||||||
|
pub fn selection_statistics<H: History>(engine: &Engine<H>) -> GlobalStatistics {
|
||||||
|
GlobalStatistics::new(&engine.statistics().global)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global statistics of the trackpoints from `start` to `end` (both included) of the selection,
|
||||||
|
/// as indexed by the buffers. `None` when the range is not in the selection.
|
||||||
|
pub fn slice_statistics<H: History>(
|
||||||
|
engine: &Engine<H>,
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
) -> Option<GlobalStatistics> {
|
||||||
|
engine
|
||||||
|
.statistics()
|
||||||
|
.slice(start, end)
|
||||||
|
.map(|stats| GlobalStatistics::new(&stats))
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! buffers {
|
||||||
|
($($name:ident),*) => {$(
|
||||||
|
pub fn $name<H: History>(engine: &Engine<H>) -> &[f64] {
|
||||||
|
&engine.statistics().$name
|
||||||
|
}
|
||||||
|
)*};
|
||||||
|
}
|
||||||
|
|
||||||
|
// One entry per trackpoint of the selection.
|
||||||
|
buffers!(
|
||||||
|
total_distance,
|
||||||
|
elevation_gain,
|
||||||
|
elevation_loss,
|
||||||
|
slope,
|
||||||
|
slope_segment_slope,
|
||||||
|
slope_segment_distance,
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
ele
|
||||||
|
);
|
||||||
|
|
||||||
|
macro_rules! optional_buffers {
|
||||||
|
($($name:ident: $type:ty),*) => {$(
|
||||||
|
/// `None` when no trackpoint of the selection has the value, else an entry per trackpoint,
|
||||||
|
/// the missing ones being NaN (`NO_TIME` for the timestamps, 0 before the first trackpoint
|
||||||
|
/// that has them for the cumulative distances and durations).
|
||||||
|
pub fn $name<H: History>(engine: &Engine<H>) -> Option<&[$type]> {
|
||||||
|
engine.statistics().$name.as_deref()
|
||||||
|
}
|
||||||
|
)*};
|
||||||
|
}
|
||||||
|
|
||||||
|
optional_buffers!(moving_distance: f64, speed: f64, hr: f64, cad: f64, atemp: f64, power: f64);
|
||||||
|
optional_buffers!(total_time: i64, moving_time: i64);
|
||||||
|
|
||||||
|
/// Timestamps in milliseconds since the epoch (`NO_TIME` when missing), see `optional_buffers`.
|
||||||
|
pub fn timestamps<H: History>(engine: &Engine<H>) -> Option<&[i64]> {
|
||||||
|
engine.statistics().time.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface, highway, SAC scale and MTB scale of the trackpoints, as intervals of trackpoints that
|
||||||
|
// have the same value: `<name>_starts` holds the index of the first trackpoint of each interval,
|
||||||
|
// and `<name>_values` its value (0 when unknown, else 1 + the index in the names of the category).
|
||||||
|
// A value that is unknown for every trackpoint is a single interval.
|
||||||
|
macro_rules! categories {
|
||||||
|
($starts:ident, $values:ident, $names:ident, $field:ident) => {
|
||||||
|
pub fn $starts<H: History>(engine: &Engine<H>) -> &[u32] {
|
||||||
|
&engine.statistics().$field.starts
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn $values<H: History>(engine: &Engine<H>) -> &[u8] {
|
||||||
|
&engine.statistics().$field.values
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The names of the category, in the order of their codes. The list only grows: a name
|
||||||
|
/// keeps its index.
|
||||||
|
pub fn $names<H: History>(engine: &Engine<H>) -> &[String] {
|
||||||
|
engine.categories().$field.names()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
categories!(surface_starts, surface_values, surfaces, surface);
|
||||||
|
categories!(highway_starts, highway_values, highways, highway);
|
||||||
|
categories!(sac_scale_starts, sac_scale_values, sac_scales, sac_scale);
|
||||||
|
categories!(mtb_scale_starts, mtb_scale_values, mtb_scales, mtb_scale);
|
||||||
|
|
||||||
|
// The anchors of the routing tool among the trackpoints of the selection (numbered as in the
|
||||||
|
// statistics). The ends of the segments are always anchors.
|
||||||
|
|
||||||
|
/// The index in the selection of each anchor.
|
||||||
|
pub fn anchor_indices<H: History>(engine: &Engine<H>) -> &[u32] {
|
||||||
|
&engine.routing().anchor_indices
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lowest map zoom level at which each anchor is shown.
|
||||||
|
pub fn anchor_zooms<H: History>(engine: &Engine<H>) -> &[u8] {
|
||||||
|
&engine.routing().anchor_zooms
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index in the selection of the first trackpoint of each selected segment that has some.
|
||||||
|
pub fn segment_starts<H: History>(engine: &Engine<H>) -> &[u32] {
|
||||||
|
&engine.routing().segment_starts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The id of each selected segment that has trackpoints, as `segment_starts`.
|
||||||
|
pub fn segment_ids<H: History>(engine: &Engine<H>) -> Vec<String> {
|
||||||
|
engine
|
||||||
|
.routing()
|
||||||
|
.segment_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.0.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes when the selected segments or their trackpoints change: the indices in the selection
|
||||||
|
/// are only valid while it stays the same.
|
||||||
|
pub fn routing_revision<H: History>(engine: &Engine<H>) -> u32 {
|
||||||
|
engine.routing().revision
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
use std::f64::consts::PI;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
TrackSegment, TrackSegmentIndex, core::utils::crossarc_lnglat, ramer_douglas_peucker_by,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Highest zoom level of the map at which an anchor can appear.
|
||||||
|
pub const MAX_ANCHOR_ZOOM: u8 = 22;
|
||||||
|
|
||||||
|
/// Details smaller than this (in meters) do not get an anchor.
|
||||||
|
const ANCHOR_EPSILON: f64 = 1.0;
|
||||||
|
|
||||||
|
const EARTH_RADIUS_METERS: f64 = 6371008.8;
|
||||||
|
|
||||||
|
/// Lowest map zoom level at which an anchor selected at `distance` meters from the line of its
|
||||||
|
/// neighbours is worth showing: the smaller the detail, the higher the zoom. The ends of a
|
||||||
|
/// segment (no distance) are always shown.
|
||||||
|
pub fn anchor_zoom(lat: f64, distance: Option<f64>) -> u8 {
|
||||||
|
let Some(distance) = distance else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let width = EARTH_RADIUS_METERS * (lat * PI / 180.0).cos() / distance;
|
||||||
|
width.log2().round().clamp(0.0, MAX_ANCHOR_ZOOM as f64) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The anchors of a segment, as `(index, zoom)` sorted by index: the trackpoints kept by the
|
||||||
|
/// Ramer-Douglas-Peucker simplification of its path, the ones for smaller details being shown at
|
||||||
|
/// higher zoom levels only.
|
||||||
|
pub fn compute_anchors(trkseg: &TrackSegment) -> Vec<(usize, u8)> {
|
||||||
|
let measure = |start: TrackSegmentIndex, end: TrackSegmentIndex, idx: TrackSegmentIndex| {
|
||||||
|
crossarc_lnglat(
|
||||||
|
trkseg[start].coordinates,
|
||||||
|
trkseg[end].coordinates,
|
||||||
|
trkseg[idx].coordinates,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
ramer_douglas_peucker_by(trkseg, &measure, ANCHOR_EPSILON)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(idx, distance)| (idx.flat, anchor_zoom(trkseg[idx].coordinates.lat, distance)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::{LngLat, Trackpoint, TrackpointChunk};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn segment(points: &[(f64, f64)]) -> TrackSegment {
|
||||||
|
let mut trkseg = TrackSegment::default();
|
||||||
|
let mut chunk = TrackpointChunk::default();
|
||||||
|
for (lng, lat) in points {
|
||||||
|
chunk.trkpt.push(Trackpoint {
|
||||||
|
coordinates: LngLat {
|
||||||
|
lng: *lng,
|
||||||
|
lat: *lat,
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
trkseg.push(chunk);
|
||||||
|
trkseg
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_zoom_depends_on_the_size_of_the_detail() {
|
||||||
|
assert_eq!(anchor_zoom(45.0, None), 0);
|
||||||
|
// 2^13 meters is close to the radius of the earth divided by 1 km
|
||||||
|
assert_eq!(anchor_zoom(0.0, Some(1_000.0)), 13);
|
||||||
|
assert!(anchor_zoom(0.0, Some(10.0)) > anchor_zoom(0.0, Some(1_000.0)));
|
||||||
|
assert_eq!(anchor_zoom(0.0, Some(1e-9)), MAX_ANCHOR_ZOOM);
|
||||||
|
assert_eq!(anchor_zoom(0.0, Some(1e9)), 0);
|
||||||
|
// the poles do not give NaN or a panic
|
||||||
|
assert_eq!(anchor_zoom(90.0, Some(10.0)), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anchors_of_a_straight_line_are_its_ends() {
|
||||||
|
let line: Vec<_> = (0..10).map(|i| (i as f64 * 0.001, 0.0)).collect();
|
||||||
|
assert_eq!(compute_anchors(&segment(&line)), vec![(0, 0), (9, 0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anchors_keep_the_corners() {
|
||||||
|
// a big corner and a small bump
|
||||||
|
let points = [
|
||||||
|
(0.0, 0.0),
|
||||||
|
(0.01, 0.0),
|
||||||
|
(0.02, 0.0),
|
||||||
|
(0.02, 0.01),
|
||||||
|
(0.02, 0.02),
|
||||||
|
(0.02, 0.020005),
|
||||||
|
(0.02, 0.03),
|
||||||
|
];
|
||||||
|
let anchors = compute_anchors(&segment(&points));
|
||||||
|
let indices: Vec<_> = anchors.iter().map(|(i, _)| *i).collect();
|
||||||
|
assert_eq!(indices, vec![0, 2, 6]);
|
||||||
|
assert_eq!(anchors[0].1, 0);
|
||||||
|
assert_eq!(anchors[2].1, 0);
|
||||||
|
assert!(anchors[1].1 > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anchors_of_short_segments() {
|
||||||
|
assert!(compute_anchors(&segment(&[])).is_empty());
|
||||||
|
assert_eq!(compute_anchors(&segment(&[(1.0, 2.0)])), vec![(0, 0)]);
|
||||||
|
assert_eq!(
|
||||||
|
compute_anchors(&segment(&[(1.0, 2.0), (1.0, 2.1)])),
|
||||||
|
vec![(0, 0), (1, 0)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
mod anchors;
|
||||||
|
mod reverse;
|
||||||
|
mod simplify;
|
||||||
|
mod smooth;
|
||||||
|
mod timestamps;
|
||||||
|
|
||||||
|
pub use anchors::*;
|
||||||
|
pub use reverse::*;
|
||||||
|
pub use simplify::*;
|
||||||
|
pub use timestamps::*;
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
//! Reversing a path: the order of its trackpoints, and of the segments and tracks that hold them.
|
||||||
|
//!
|
||||||
|
//! The timestamps are mirrored, so that the reversed path goes through the same period of time:
|
||||||
|
//! a trackpoint at time `t` of a part that spans `start..end` ends up at `start + end - t`. When
|
||||||
|
//! the part is made of several ones (the segments of a track, the tracks of a file), the gaps
|
||||||
|
//! between them are kept: every part comes after the previous one in the new order, as late as the
|
||||||
|
//! original one came after the part that followed it.
|
||||||
|
|
||||||
|
use crate::{Track, TrackSegment, Trackpoint};
|
||||||
|
|
||||||
|
/// The time of the first trackpoint of a segment.
|
||||||
|
pub fn start_time(segment: &TrackSegment) -> Option<i64> {
|
||||||
|
segment.iter().next().and_then(|trkpt| trkpt.time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The time of the last trackpoint of a segment.
|
||||||
|
pub fn end_time(segment: &TrackSegment) -> Option<i64> {
|
||||||
|
segment.last_index().and_then(|idx| segment[idx].time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The time of the first trackpoint of a track.
|
||||||
|
pub fn track_start_time(track: &Track) -> Option<i64> {
|
||||||
|
track.trkseg.first().and_then(start_time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The time of the last trackpoint of a track.
|
||||||
|
pub fn track_end_time(track: &Track) -> Option<i64> {
|
||||||
|
track.trkseg.last().and_then(end_time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverses the trackpoints of a segment.
|
||||||
|
///
|
||||||
|
/// `original_next` is the time at which what came after the segment started, and `new_previous`
|
||||||
|
/// the time at which what comes before it now ends: the segment is mirrored to start there. Both
|
||||||
|
/// are those of the segment itself if the segment is reversed on its own, which are the ones
|
||||||
|
/// used when they are missing (`new_previous` is the start of the segment, and `original_next`
|
||||||
|
/// follows from the duration of the segment). Without timestamps, the trackpoints are only put
|
||||||
|
/// in the opposite order.
|
||||||
|
pub fn reverse_segment(
|
||||||
|
segment: &mut TrackSegment,
|
||||||
|
original_next: Option<i64>,
|
||||||
|
new_previous: Option<i64>,
|
||||||
|
) {
|
||||||
|
let (original_start, original_end) = (start_time(segment), end_time(segment));
|
||||||
|
let new_previous = new_previous.or(original_start);
|
||||||
|
let original_next = original_next.or_else(|| {
|
||||||
|
// the segment lasts as long as before
|
||||||
|
Some(new_previous? + original_end? - original_start?)
|
||||||
|
});
|
||||||
|
|
||||||
|
let len = segment.len();
|
||||||
|
let mut points: Vec<Trackpoint> = segment.iter().cloned().collect();
|
||||||
|
if let (Some(next), Some(previous), Some(end)) = (original_next, new_previous, original_end) {
|
||||||
|
let new_start = previous + next - end;
|
||||||
|
for point in points.iter_mut() {
|
||||||
|
point.time = point.time.map(|time| new_start + (end - time));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points.reverse();
|
||||||
|
segment.splice(0, len, points);
|
||||||
|
segment.rev_id = Default::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes a segment go back where it started: a reversed copy of it is added after its last
|
||||||
|
/// trackpoint, without the first trackpoint of the copy, which is the last one of the segment.
|
||||||
|
///
|
||||||
|
/// The timestamps of the copy go on from the end of the segment: it would start at the time of the
|
||||||
|
/// last trackpoint, and it lasts as long as the segment. Only the end of the segment changes, so
|
||||||
|
/// its other chunks stay shared.
|
||||||
|
pub fn round_trip(segment: &mut TrackSegment) {
|
||||||
|
let len = segment.len();
|
||||||
|
let end = end_time(segment);
|
||||||
|
let mut back = segment.clone();
|
||||||
|
reverse_segment(&mut back, end, end);
|
||||||
|
segment.splice(len, len, back.iter().skip(1).cloned().collect());
|
||||||
|
segment.rev_id = Default::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverses the segments of a track, and their trackpoints: see [`reverse_segment`] for the
|
||||||
|
/// meaning of the times, which are the ones of the track as a whole.
|
||||||
|
pub fn reverse_segments(
|
||||||
|
segments: &mut [TrackSegment],
|
||||||
|
original_next: Option<i64>,
|
||||||
|
new_previous: Option<i64>,
|
||||||
|
) {
|
||||||
|
segments.reverse();
|
||||||
|
let (mut next, mut previous) = (original_next, new_previous);
|
||||||
|
for segment in segments.iter_mut() {
|
||||||
|
// the next one is the one that was before, which started here
|
||||||
|
let original_start = start_time(segment);
|
||||||
|
reverse_segment(segment, next, previous);
|
||||||
|
next = original_start;
|
||||||
|
previous = end_time(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverses the tracks of a file, and their segments and trackpoints.
|
||||||
|
pub fn reverse_tracks(tracks: &mut [Track], original_next: Option<i64>, new_previous: Option<i64>) {
|
||||||
|
tracks.reverse();
|
||||||
|
let (mut next, mut previous) = (original_next, new_previous);
|
||||||
|
for track in tracks.iter_mut() {
|
||||||
|
let original_start = track_start_time(track);
|
||||||
|
reverse_segments(&mut track.trkseg, next, previous);
|
||||||
|
next = original_start;
|
||||||
|
previous = track_end_time(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::LngLat;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn segment(points: &[(f64, Option<i64>)]) -> TrackSegment {
|
||||||
|
let mut segment = TrackSegment::default();
|
||||||
|
let points = points
|
||||||
|
.iter()
|
||||||
|
.map(|(lng, time)| Trackpoint {
|
||||||
|
coordinates: LngLat {
|
||||||
|
lng: *lng,
|
||||||
|
lat: 0.0,
|
||||||
|
},
|
||||||
|
time: *time,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
segment.splice(0, 0, points);
|
||||||
|
segment
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lngs(segment: &TrackSegment) -> Vec<f64> {
|
||||||
|
segment.iter().map(|p| p.coordinates.lng).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn times(segment: &TrackSegment) -> Vec<Option<i64>> {
|
||||||
|
segment.iter().map(|p| p.time).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_segment_is_mirrored_in_its_own_time_span() {
|
||||||
|
let mut s = segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(40))]);
|
||||||
|
let (start, end) = (start_time(&s), end_time(&s));
|
||||||
|
reverse_segment(&mut s, end, start);
|
||||||
|
assert_eq!(lngs(&s), [2.0, 1.0, 0.0]);
|
||||||
|
// same span, the durations between points follow the points
|
||||||
|
assert_eq!(times(&s), [Some(0), Some(30), Some(40)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reverse_segment_defaults() {
|
||||||
|
// without the times around, the segment is mirrored where it is
|
||||||
|
let mut s = segment(&[(0.0, Some(100)), (1.0, Some(130))]);
|
||||||
|
reverse_segment(&mut s, None, None);
|
||||||
|
assert_eq!(times(&s), [Some(100), Some(130)]);
|
||||||
|
assert_eq!(lngs(&s), [1.0, 0.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_segment_without_times_is_only_reversed() {
|
||||||
|
let mut s = segment(&[(0.0, None), (1.0, None), (2.0, None)]);
|
||||||
|
reverse_segment(&mut s, None, None);
|
||||||
|
assert_eq!(lngs(&s), [2.0, 1.0, 0.0]);
|
||||||
|
assert_eq!(times(&s), [None, None, None]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_round_trip_goes_back_in_time_from_the_end() {
|
||||||
|
let mut s = segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(40))]);
|
||||||
|
let first = s.rev_id;
|
||||||
|
round_trip(&mut s);
|
||||||
|
assert_ne!(s.rev_id, first);
|
||||||
|
// the last trackpoint is the turning point, it is not repeated
|
||||||
|
assert_eq!(lngs(&s), [0.0, 1.0, 2.0, 1.0, 0.0]);
|
||||||
|
// the way back lasts as long as the way there, and starts when it ended
|
||||||
|
assert_eq!(times(&s), [Some(0), Some(10), Some(40), Some(70), Some(80)]);
|
||||||
|
// and it is a segment whose ends are anchors
|
||||||
|
assert_eq!(s[0].anchor, Some(0));
|
||||||
|
assert_eq!(s[4].anchor, Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_round_trip_without_times() {
|
||||||
|
let mut s = segment(&[(0.0, None), (1.0, None)]);
|
||||||
|
round_trip(&mut s);
|
||||||
|
assert_eq!(lngs(&s), [0.0, 1.0, 0.0]);
|
||||||
|
assert_eq!(times(&s), [None; 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_segments_keep_the_gaps_between_them() {
|
||||||
|
// 0..20, then 100..120: a gap of 80
|
||||||
|
let mut segments = vec![
|
||||||
|
segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(20))]),
|
||||||
|
segment(&[(3.0, Some(100)), (4.0, Some(110)), (5.0, Some(120))]),
|
||||||
|
];
|
||||||
|
let (end, start) = (end_time(&segments[1]), start_time(&segments[0]));
|
||||||
|
reverse_segments(&mut segments, end, start);
|
||||||
|
assert_eq!(lngs(&segments[0]), [5.0, 4.0, 3.0]);
|
||||||
|
assert_eq!(lngs(&segments[1]), [2.0, 1.0, 0.0]);
|
||||||
|
assert_eq!(times(&segments[0]), [Some(0), Some(10), Some(20)]);
|
||||||
|
// the gap is still 80
|
||||||
|
assert_eq!(times(&segments[1]), [Some(100), Some(110), Some(120)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tracks_are_reversed_like_segments() {
|
||||||
|
let track = |segments: Vec<TrackSegment>| Track {
|
||||||
|
trkseg: segments,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut tracks = vec![
|
||||||
|
track(vec![
|
||||||
|
segment(&[(0.0, Some(0)), (1.0, Some(10))]),
|
||||||
|
segment(&[(2.0, Some(50)), (3.0, Some(60))]),
|
||||||
|
]),
|
||||||
|
track(vec![segment(&[(4.0, Some(200)), (5.0, Some(230))])]),
|
||||||
|
];
|
||||||
|
let (end, start) = (track_end_time(&tracks[1]), track_start_time(&tracks[0]));
|
||||||
|
reverse_tracks(&mut tracks, end, start);
|
||||||
|
let all: Vec<f64> = tracks
|
||||||
|
.iter()
|
||||||
|
.flat_map(|t| t.trkseg.iter().flat_map(lngs))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(all, [5.0, 4.0, 3.0, 2.0, 1.0, 0.0]);
|
||||||
|
// 3 segments: durations 10, 10, 30 and gaps 40 and 140 are reversed to 30, 10, 10 and
|
||||||
|
// gaps 140 and 40 (the first part of the time is the longest)
|
||||||
|
assert_eq!(times(&tracks[0].trkseg[0]), [Some(0), Some(30)]);
|
||||||
|
assert_eq!(times(&tracks[1].trkseg[0]), [Some(170), Some(180)]);
|
||||||
|
assert_eq!(times(&tracks[1].trkseg[1]), [Some(220), Some(230)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
use crate::{
|
||||||
|
core::gpx::{TrackSegment, TrackSegmentIndex},
|
||||||
|
core::utils::{crossarc, crossarc_lnglat},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn ramer_douglas_peucker<F>(
|
||||||
|
trkseg: &TrackSegment,
|
||||||
|
mapping: &F,
|
||||||
|
epsilon: f64,
|
||||||
|
) -> Vec<TrackSegmentIndex>
|
||||||
|
where
|
||||||
|
F: Fn(TrackSegmentIndex) -> (f64, f64),
|
||||||
|
{
|
||||||
|
let measure = |start, end, idx| {
|
||||||
|
let (start, end, pt) = (mapping(start), mapping(end), mapping(idx));
|
||||||
|
crossarc(start.0, start.1, end.0, end.1, pt.0, pt.1)
|
||||||
|
};
|
||||||
|
ramer_douglas_peucker_by(trkseg, &measure, epsilon)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(idx, _)| idx)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ramer-Douglas-Peucker with any distance from a point (the last argument) to the line made of
|
||||||
|
/// two others (the first ones). Gives the kept points, with the distance at which each of them
|
||||||
|
/// was selected (`None` for the ends of the segment, which are always kept).
|
||||||
|
pub fn ramer_douglas_peucker_by<M>(
|
||||||
|
trkseg: &TrackSegment,
|
||||||
|
measure: &M,
|
||||||
|
epsilon: f64,
|
||||||
|
) -> Vec<(TrackSegmentIndex, Option<f64>)>
|
||||||
|
where
|
||||||
|
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
|
||||||
|
{
|
||||||
|
match trkseg.len() {
|
||||||
|
0 => vec![],
|
||||||
|
1 => vec![(trkseg.first_index().unwrap(), None)],
|
||||||
|
2 => vec![
|
||||||
|
(trkseg.first_index().unwrap(), None),
|
||||||
|
(trkseg.last_index().unwrap(), None),
|
||||||
|
],
|
||||||
|
_ => {
|
||||||
|
let first = trkseg.first_index().unwrap();
|
||||||
|
let last = trkseg.last_index().unwrap();
|
||||||
|
|
||||||
|
let mut kept = vec![(first, None)];
|
||||||
|
ramer_douglas_peucker_helper(trkseg, first, last, measure, epsilon, &mut kept);
|
||||||
|
kept.push((last, None));
|
||||||
|
kept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ramer_douglas_peucker_helper<M>(
|
||||||
|
trkseg: &TrackSegment,
|
||||||
|
start: TrackSegmentIndex,
|
||||||
|
end: TrackSegmentIndex,
|
||||||
|
measure: &M,
|
||||||
|
epsilon: f64,
|
||||||
|
kept: &mut Vec<(TrackSegmentIndex, Option<f64>)>,
|
||||||
|
) where
|
||||||
|
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
|
||||||
|
{
|
||||||
|
let mut max_idx = None;
|
||||||
|
let mut max_dist = 0.0;
|
||||||
|
|
||||||
|
let mut cur = trkseg.next_index(Some(start));
|
||||||
|
while let Some(idx) = cur {
|
||||||
|
if idx == end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dist = measure(start, end, idx);
|
||||||
|
if dist > max_dist {
|
||||||
|
max_idx = Some(idx);
|
||||||
|
max_dist = dist;
|
||||||
|
}
|
||||||
|
|
||||||
|
cur = trkseg.next_index(cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) {
|
||||||
|
ramer_douglas_peucker_helper(trkseg, start, idx, measure, epsilon, kept);
|
||||||
|
kept.push((idx, Some(max_dist)));
|
||||||
|
ramer_douglas_peucker_helper(trkseg, idx, end, measure, epsilon, kept);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowest tolerance (in meters) of the reduction of a segment, see [`reduction_distances`].
|
||||||
|
pub const MIN_REDUCE_TOLERANCE: f64 = 0.1;
|
||||||
|
|
||||||
|
/// The distance (in meters) from a trackpoint to the line between two others of a segment.
|
||||||
|
fn lnglat_measure(
|
||||||
|
trkseg: &TrackSegment,
|
||||||
|
) -> impl Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64 + '_ {
|
||||||
|
move |start, end, idx| {
|
||||||
|
crossarc_lnglat(
|
||||||
|
trkseg[start].coordinates,
|
||||||
|
trkseg[end].coordinates,
|
||||||
|
trkseg[idx].coordinates,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The indices of the trackpoints of a segment that are kept when it is reduced with `tolerance`
|
||||||
|
/// meters (Ramer-Douglas-Peucker), in order. The ends are always kept.
|
||||||
|
pub fn reduce_indices(trkseg: &TrackSegment, tolerance: f64) -> Vec<usize> {
|
||||||
|
ramer_douglas_peucker_by(trkseg, &lnglat_measure(trkseg), tolerance)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(idx, _)| idx.flat)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For each trackpoint of a segment, the tolerance (in meters) up to which it is kept by
|
||||||
|
/// [`reduce_indices`]: infinite for the ends, which are always kept, zero for the trackpoints that
|
||||||
|
/// are not selected even at [`MIN_REDUCE_TOLERANCE`]. A trackpoint is kept with a tolerance if its
|
||||||
|
/// value is greater than it.
|
||||||
|
///
|
||||||
|
/// A trackpoint is only selected if the ones that split the path before it are, so its value is
|
||||||
|
/// the smallest distance at which it or one of them was selected.
|
||||||
|
pub fn reduction_distances(trkseg: &TrackSegment) -> Vec<f64> {
|
||||||
|
let mut distances = vec![0.0; trkseg.len()];
|
||||||
|
let (Some(first), Some(last)) = (trkseg.first_index(), trkseg.last_index()) else {
|
||||||
|
return distances;
|
||||||
|
};
|
||||||
|
distances[first.flat] = f64::INFINITY;
|
||||||
|
distances[last.flat] = f64::INFINITY;
|
||||||
|
if trkseg.len() > 2 {
|
||||||
|
let measure = lnglat_measure(trkseg);
|
||||||
|
reduction_distances_helper(trkseg, first, last, &measure, f64::INFINITY, &mut distances);
|
||||||
|
}
|
||||||
|
distances
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reduction_distances_helper<M>(
|
||||||
|
trkseg: &TrackSegment,
|
||||||
|
start: TrackSegmentIndex,
|
||||||
|
end: TrackSegmentIndex,
|
||||||
|
measure: &M,
|
||||||
|
bound: f64,
|
||||||
|
distances: &mut [f64],
|
||||||
|
) where
|
||||||
|
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
|
||||||
|
{
|
||||||
|
let mut max_idx = None;
|
||||||
|
let mut max_dist = 0.0;
|
||||||
|
let mut cur = trkseg.next_index(Some(start));
|
||||||
|
while let Some(idx) = cur {
|
||||||
|
if idx == end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let dist = measure(start, end, idx);
|
||||||
|
if dist > max_dist {
|
||||||
|
max_idx = Some(idx);
|
||||||
|
max_dist = dist;
|
||||||
|
}
|
||||||
|
cur = trkseg.next_index(cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(idx) = max_idx.filter(|_| max_dist > MIN_REDUCE_TOLERANCE) {
|
||||||
|
let distance = max_dist.min(bound);
|
||||||
|
distances[idx.flat] = distance;
|
||||||
|
reduction_distances_helper(trkseg, start, idx, measure, distance, distances);
|
||||||
|
reduction_distances_helper(trkseg, idx, end, measure, distance, distances);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::{Trackpoint, TrackpointChunk};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn segment(eles: &[f64]) -> TrackSegment {
|
||||||
|
let mut trkseg = TrackSegment::default();
|
||||||
|
// several small chunks, to also exercise the chunk boundaries
|
||||||
|
for part in eles.chunks(3) {
|
||||||
|
let mut chunk = TrackpointChunk::default();
|
||||||
|
for ele in part {
|
||||||
|
chunk.trkpt.push(Trackpoint {
|
||||||
|
ele: *ele,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
trkseg.push(chunk);
|
||||||
|
}
|
||||||
|
trkseg
|
||||||
|
}
|
||||||
|
|
||||||
|
fn simplify(eles: &[f64], epsilon: f64) -> Vec<usize> {
|
||||||
|
let trkseg = segment(eles);
|
||||||
|
ramer_douglas_peucker(&trkseg, &|idx| (idx.flat as f64, trkseg[idx].ele), epsilon)
|
||||||
|
.iter()
|
||||||
|
.map(|idx| idx.flat)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_and_single_point() {
|
||||||
|
assert!(simplify(&[], 1.0).is_empty());
|
||||||
|
assert_eq!(simplify(&[5.0], 1.0), vec![0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_collinear_points_are_removed() {
|
||||||
|
let eles: Vec<f64> = (0..20).map(|i| i as f64 * 2.0).collect();
|
||||||
|
assert_eq!(simplify(&eles, 0.1), vec![0, 19]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_peak_is_kept_when_above_epsilon() {
|
||||||
|
let eles = [0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0];
|
||||||
|
assert_eq!(simplify(&eles, 5.0), vec![0, 3, 6]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_peak_is_dropped_when_below_epsilon() {
|
||||||
|
let eles = [0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0];
|
||||||
|
assert_eq!(simplify(&eles, 1.0), vec![0, 6]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_indices_are_sorted_and_keep_extremities() {
|
||||||
|
let eles = [0.0, 5.0, -3.0, 8.0, 1.0, 9.0, -4.0, 2.0, 7.0, 0.0];
|
||||||
|
let kept = simplify(&eles, 0.5);
|
||||||
|
assert_eq!(kept.first(), Some(&0));
|
||||||
|
assert_eq!(kept.last(), Some(&9));
|
||||||
|
assert!(kept.windows(2).all(|w| w[0] < w[1]));
|
||||||
|
// a larger epsilon never keeps more points
|
||||||
|
assert!(simplify(&eles, 5.0).len() <= kept.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#[macro_export]
|
||||||
|
macro_rules! for_each_window {
|
||||||
|
(
|
||||||
|
$trkseg:expr,
|
||||||
|
$left:expr,
|
||||||
|
$right:expr,
|
||||||
|
$window:expr,
|
||||||
|
|$a:ident, $b:ident| $distance:expr,
|
||||||
|
|$i:ident, $l:ident, $r:ident| $body:block,
|
||||||
|
) => {{
|
||||||
|
let mut window_start = $left.unwrap_or_default();
|
||||||
|
let mut window_center = $left;
|
||||||
|
let mut window_end = $left.unwrap_or_default();
|
||||||
|
while let Some(idx) = window_center {
|
||||||
|
// advance left if needed
|
||||||
|
while window_start != idx && {
|
||||||
|
let $a = window_start;
|
||||||
|
let $b = idx;
|
||||||
|
$distance
|
||||||
|
} > $window {
|
||||||
|
if let Some(next) = $trkseg.next_index(Some(window_start)) {
|
||||||
|
if next == idx {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
window_start = next;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// advance right if needed
|
||||||
|
window_end = window_end.max(idx);
|
||||||
|
while let Some(next) = $trkseg.next_index(Some(window_end)) {
|
||||||
|
if window_end != idx && {
|
||||||
|
let $a = idx;
|
||||||
|
let $b = next;
|
||||||
|
$distance
|
||||||
|
} > $window {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
window_end = next;
|
||||||
|
}
|
||||||
|
// apply body
|
||||||
|
let $i = idx;
|
||||||
|
let $l = window_start;
|
||||||
|
let $r = window_end;
|
||||||
|
$body
|
||||||
|
// go next
|
||||||
|
if window_center == $right {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
window_center = $trkseg.next_index(window_center);
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::{TrackSegment, Trackpoint, TrackpointChunk};
|
||||||
|
|
||||||
|
fn segment(eles: &[f64]) -> TrackSegment {
|
||||||
|
let mut trkseg = TrackSegment::default();
|
||||||
|
// several small chunks, to also exercise the chunk boundaries
|
||||||
|
for part in eles.chunks(3) {
|
||||||
|
let mut chunk = TrackpointChunk::default();
|
||||||
|
for ele in part {
|
||||||
|
chunk.trkpt.push(Trackpoint {
|
||||||
|
ele: *ele,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
trkseg.push(chunk);
|
||||||
|
}
|
||||||
|
trkseg
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns (center, left, right) flat indices for each visited center.
|
||||||
|
fn windows(len: usize, window: f64) -> Vec<(usize, usize, usize)> {
|
||||||
|
let trkseg = segment(&vec![0.0; len]);
|
||||||
|
let mut visited = vec![];
|
||||||
|
for_each_window!(
|
||||||
|
trkseg,
|
||||||
|
trkseg.first_index(),
|
||||||
|
trkseg.last_index(),
|
||||||
|
window,
|
||||||
|
|a, b| (b.flat - a.flat) as f64,
|
||||||
|
|i, l, r| {
|
||||||
|
visited.push((i.flat, l.flat, r.flat));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
visited
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_window_bounds() {
|
||||||
|
let visited = windows(10, 2.0);
|
||||||
|
assert_eq!(visited.len(), 10);
|
||||||
|
for (i, l, r) in visited {
|
||||||
|
assert_eq!(l, i.saturating_sub(2), "left of {i}");
|
||||||
|
assert_eq!(r, (i + 2).min(9), "right of {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_window_always_includes_neighbours() {
|
||||||
|
for (i, l, r) in windows(6, 0.0) {
|
||||||
|
assert_eq!((l, r), (i.saturating_sub(1), (i + 1).min(5)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_window_larger_than_segment() {
|
||||||
|
for (_, l, r) in windows(5, 100.0) {
|
||||||
|
assert_eq!((l, r), (0, 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_window_sub_range() {
|
||||||
|
let trkseg = segment(&[0.0; 10]);
|
||||||
|
let mut centers = vec![];
|
||||||
|
let mut first = trkseg.first_index();
|
||||||
|
for _ in 0..3 {
|
||||||
|
first = trkseg.next_index(first);
|
||||||
|
}
|
||||||
|
let mut last = first;
|
||||||
|
for _ in 0..2 {
|
||||||
|
last = trkseg.next_index(last);
|
||||||
|
}
|
||||||
|
for_each_window!(
|
||||||
|
trkseg,
|
||||||
|
first,
|
||||||
|
last,
|
||||||
|
1.0,
|
||||||
|
|a, b| (b.flat - a.flat) as f64,
|
||||||
|
|i, _l, _r| {
|
||||||
|
centers.push(i.flat);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(centers, vec![3, 4, 5]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
use crate::{Statistics, TrackSegment, Trackpoint, distance};
|
||||||
|
|
||||||
|
/// The new timestamps of the trackpoints of `segments`, one after the other: each segment goes on
|
||||||
|
/// from the end of the previous one, the first one starts at `start_time` (in milliseconds).
|
||||||
|
///
|
||||||
|
/// A segment that has timestamps keeps its durations multiplied by `ratio`; one that has none
|
||||||
|
/// (judged by its first trackpoint) follows the previous trackpoint at `speed` (km/h). See
|
||||||
|
/// [`shifted_and_compressed`] and [`with_timestamps`].
|
||||||
|
pub fn changed_segment_times(
|
||||||
|
segments: &[&TrackSegment],
|
||||||
|
start_time: i64,
|
||||||
|
speed: f64,
|
||||||
|
ratio: f64,
|
||||||
|
) -> Vec<Vec<Option<i64>>> {
|
||||||
|
let mut last: Option<Trackpoint> = None;
|
||||||
|
segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| {
|
||||||
|
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
|
||||||
|
let Some(first) = points.first() else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let start = last.clone().unwrap_or_else(|| {
|
||||||
|
let mut start = first.clone();
|
||||||
|
start.time = Some(start_time);
|
||||||
|
start
|
||||||
|
});
|
||||||
|
let points = if first.time.is_none() {
|
||||||
|
with_timestamps(points, Some(speed), Some(&start), Some(start_time))
|
||||||
|
} else {
|
||||||
|
shifted_and_compressed(points, Some(speed), ratio, &start)
|
||||||
|
};
|
||||||
|
last = points.last().cloned();
|
||||||
|
points.iter().map(|point| point.time).collect()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes up timestamps for the trackpoints of `segments`, one after the other: they start at
|
||||||
|
/// `start_time` (in milliseconds) and last `total_time` seconds together, the longer and the
|
||||||
|
/// steeper an interval the more of it. Each segment goes on from the end of the previous one.
|
||||||
|
pub fn artificial_segment_times(
|
||||||
|
segments: &[&TrackSegment],
|
||||||
|
start_time: i64,
|
||||||
|
total_time: f64,
|
||||||
|
) -> Vec<Vec<Option<i64>>> {
|
||||||
|
let mut weights: Vec<Vec<f64>> = segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| {
|
||||||
|
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
|
||||||
|
let slopes: Vec<f64> = Statistics::compute(segment)
|
||||||
|
.local
|
||||||
|
.iter()
|
||||||
|
.map(|l| l.slope)
|
||||||
|
.collect();
|
||||||
|
artificial_weights(&points, &slopes)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut total: f64 = weights.iter().flatten().sum();
|
||||||
|
if total.is_nan() || total <= 0.0 {
|
||||||
|
// no distance to share the time by: every interval takes as long
|
||||||
|
for weights in &mut weights {
|
||||||
|
weights.iter_mut().for_each(|w| *w = 1.0);
|
||||||
|
}
|
||||||
|
total = weights.iter().map(Vec::len).sum::<usize>() as f64;
|
||||||
|
}
|
||||||
|
let ms_per_weight = if total > 0.0 {
|
||||||
|
total_time * 1000.0 / total
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut start = start_time;
|
||||||
|
segments
|
||||||
|
.iter()
|
||||||
|
.zip(&weights)
|
||||||
|
.map(|(segment, weights)| {
|
||||||
|
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
|
||||||
|
if points.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let points = with_artificial_timestamps(points, weights, ms_per_weight, start);
|
||||||
|
// the next segment goes on from the end of this one
|
||||||
|
start = points.last().and_then(|point| point.time).unwrap_or(start);
|
||||||
|
points.iter().map(|point| point.time).collect()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the trackpoints `start..end` of `trkpt` by `points`, keeping the timestamps of the
|
||||||
|
/// whole path consistent. It is the work of the old `replaceTrackPoints` of the TS library.
|
||||||
|
///
|
||||||
|
/// The timestamps only matter if the path has some, or if `speed` (in km/h) is given: then the
|
||||||
|
/// new points that have none get some (a trackpoint after the previous one at `speed`, or one
|
||||||
|
/// second after it without a speed), and so do the points before or after the range if they
|
||||||
|
/// had none. `start_time` is the time of the first trackpoint when nothing else gives it.
|
||||||
|
/// New points that have timestamps are shifted if they are too early to follow the previous
|
||||||
|
/// trackpoint, and so are the points after the range. With `remove_gaps`, the new points are
|
||||||
|
/// also brought closer to the previous trackpoint if they come long after it.
|
||||||
|
///
|
||||||
|
/// Panics if `start..end` is not inside `trkpt`.
|
||||||
|
pub fn replace_trackpoints(
|
||||||
|
trkpt: &mut Vec<Trackpoint>,
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
mut points: Vec<Trackpoint>,
|
||||||
|
speed: Option<f64>,
|
||||||
|
start_time: Option<i64>,
|
||||||
|
remove_gaps: bool,
|
||||||
|
) {
|
||||||
|
assert!(start <= end && end <= trkpt.len());
|
||||||
|
let has_times = trkpt.first().is_some_and(|first| first.time.is_some());
|
||||||
|
|
||||||
|
if speed.is_some() || has_times {
|
||||||
|
if start > 0 && trkpt[0].time.is_none() {
|
||||||
|
// the points before the range have no timestamps
|
||||||
|
let before: Vec<_> = trkpt.drain(..start).collect();
|
||||||
|
trkpt.splice(0..0, with_timestamps(before, speed, None, start_time));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !points.is_empty() {
|
||||||
|
let last = start.checked_sub(1).map(|i| &trkpt[i]);
|
||||||
|
let missing =
|
||||||
|
points[0].time.is_none() || points.get(1).is_some_and(|p| p.time.is_none());
|
||||||
|
if missing {
|
||||||
|
points = with_timestamps(points, speed, last, start_time);
|
||||||
|
} else if let Some(last) = last {
|
||||||
|
if is_before(points[0].time, last.time) {
|
||||||
|
// too early
|
||||||
|
points = shifted_and_compressed(points, speed, 1.0, last);
|
||||||
|
} else if remove_gaps {
|
||||||
|
if same_place(&points[0], last) {
|
||||||
|
// the same place: the new points start at the previous trackpoint, which
|
||||||
|
// is the new first one
|
||||||
|
if is_before(last.time, points[0].time) {
|
||||||
|
points = shifted_and_compressed(points, speed, 1.0, last);
|
||||||
|
points.remove(0);
|
||||||
|
}
|
||||||
|
} else if matches!((points[0].time, last.time), (Some(new), Some(old)) if new - old > 1000)
|
||||||
|
{
|
||||||
|
// another place: the new points start one second after the previous one
|
||||||
|
let mut artificial_last = points[0].clone();
|
||||||
|
artificial_last.time = last.time.map(|time| time + 1000);
|
||||||
|
points = shifted_and_compressed(points, speed, 1.0, &artificial_last);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if end < trkpt.len() {
|
||||||
|
// the points after the range
|
||||||
|
let last = points
|
||||||
|
.last()
|
||||||
|
.or_else(|| start.checked_sub(1).map(|i| &trkpt[i]))
|
||||||
|
.cloned();
|
||||||
|
if trkpt[end].time.is_none() {
|
||||||
|
let after: Vec<_> = trkpt.drain(end..).collect();
|
||||||
|
let after = with_timestamps(after, speed, last.as_ref(), start_time);
|
||||||
|
trkpt.extend(after);
|
||||||
|
} else if let Some(last) = last
|
||||||
|
&& is_before(trkpt[end].time, last.time)
|
||||||
|
{
|
||||||
|
let after: Vec<_> = trkpt.drain(end..).collect();
|
||||||
|
trkpt.extend(shifted_and_compressed(after, speed, 1.0, &last));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trkpt.splice(start..end, points);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long each interval between two consecutive trackpoints of a segment should last, relative
|
||||||
|
/// to the others, for a made-up pace: the longer the interval, and the steeper it goes up, the
|
||||||
|
/// longer. `slopes` has the slope (in %) at each trackpoint.
|
||||||
|
pub fn artificial_weights(points: &[Trackpoint], slopes: &[f64]) -> Vec<f64> {
|
||||||
|
points
|
||||||
|
.windows(2)
|
||||||
|
.zip(slopes)
|
||||||
|
.map(|(pair, slope)| {
|
||||||
|
let km = distance(pair[0].coordinates, pair[1].coordinates);
|
||||||
|
km * (0.5 + 1.0 / (1.0 + (-0.2 * slope).exp()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `points` with made-up timestamps: the first one is at `start`, and each next one follows the
|
||||||
|
/// previous one after `weights[i]` times `ms_per_weight` milliseconds (see [`artificial_weights`]).
|
||||||
|
pub fn with_artificial_timestamps(
|
||||||
|
mut points: Vec<Trackpoint>,
|
||||||
|
weights: &[f64],
|
||||||
|
ms_per_weight: f64,
|
||||||
|
start: i64,
|
||||||
|
) -> Vec<Trackpoint> {
|
||||||
|
let mut elapsed = 0.0;
|
||||||
|
for (i, point) in points.iter_mut().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
elapsed += weights[i - 1] * ms_per_weight;
|
||||||
|
}
|
||||||
|
point.time = Some(start + elapsed as i64);
|
||||||
|
}
|
||||||
|
points
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_place(a: &Trackpoint, b: &Trackpoint) -> bool {
|
||||||
|
a.coordinates.lng == b.coordinates.lng && a.coordinates.lat == b.coordinates.lat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether both times are known and `a` is before `b`.
|
||||||
|
fn is_before(a: Option<i64>, b: Option<i64>) -> bool {
|
||||||
|
matches!((a, b), (Some(a), Some(b)) if a < b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The time at which `b` is reached after `a`, at `speed` km/h: one second later without a
|
||||||
|
/// speed, unknown if `a` has no time.
|
||||||
|
///
|
||||||
|
/// The old code gave an invalid date for a null or negative speed: it is as if there was none.
|
||||||
|
fn timestamp_after(a: &Trackpoint, b: &Trackpoint, speed: Option<f64>) -> Option<i64> {
|
||||||
|
let time = a.time?;
|
||||||
|
match speed.filter(|speed| speed.is_finite() && *speed > 0.0) {
|
||||||
|
None => Some(time + 1000),
|
||||||
|
Some(speed) => {
|
||||||
|
let km = distance(a.coordinates, b.coordinates);
|
||||||
|
Some(time + (3_600_000.0 * km / speed) as i64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `points` with the timestamps that follow `last` (the trackpoint before them), one after the
|
||||||
|
/// other. Without `last`, the first one is at `start_time`.
|
||||||
|
///
|
||||||
|
/// Unlike the old code, a `last` without a time does not get `start_time` itself, only the copy
|
||||||
|
/// used here.
|
||||||
|
pub fn with_timestamps(
|
||||||
|
points: Vec<Trackpoint>,
|
||||||
|
speed: Option<f64>,
|
||||||
|
last: Option<&Trackpoint>,
|
||||||
|
start_time: Option<i64>,
|
||||||
|
) -> Vec<Trackpoint> {
|
||||||
|
let Some(first) = points.first() else {
|
||||||
|
return points;
|
||||||
|
};
|
||||||
|
let mut last = match last {
|
||||||
|
Some(last) if last.time.is_some() => last.clone(),
|
||||||
|
other => {
|
||||||
|
let mut last = other.unwrap_or(first).clone();
|
||||||
|
last.time = start_time;
|
||||||
|
last
|
||||||
|
}
|
||||||
|
};
|
||||||
|
points
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut point| {
|
||||||
|
point.time = timestamp_after(&last, &point, speed);
|
||||||
|
last = point.clone();
|
||||||
|
point
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `points` with their timestamps moved so that the first one follows `last`, and their durations
|
||||||
|
/// multiplied by `ratio`. The ones that have no timestamp follow the previous one, as in
|
||||||
|
/// [`with_timestamps`]. Without a time for `last`, or for the first point, none is known.
|
||||||
|
pub fn shifted_and_compressed(
|
||||||
|
points: Vec<Trackpoint>,
|
||||||
|
speed: Option<f64>,
|
||||||
|
ratio: f64,
|
||||||
|
last: &Trackpoint,
|
||||||
|
) -> Vec<Trackpoint> {
|
||||||
|
let Some(first) = points.first() else {
|
||||||
|
return points;
|
||||||
|
};
|
||||||
|
let start = timestamp_after(last, first, speed);
|
||||||
|
let first_time = first.time;
|
||||||
|
let mut previous = first.clone();
|
||||||
|
points
|
||||||
|
.into_iter()
|
||||||
|
.map(|point| {
|
||||||
|
let mut shifted = point.clone();
|
||||||
|
shifted.time = match point.time {
|
||||||
|
None => timestamp_after(&previous, &point, speed),
|
||||||
|
Some(time) => start
|
||||||
|
.zip(first_time)
|
||||||
|
.map(|(start, first)| start + (ratio * (time - first) as f64) as i64),
|
||||||
|
};
|
||||||
|
previous = shifted.clone();
|
||||||
|
shifted
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::LngLat;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A point every 1 km along the equator... about, at 0.009 degrees of longitude.
|
||||||
|
fn point(i: usize, time: Option<i64>) -> Trackpoint {
|
||||||
|
Trackpoint {
|
||||||
|
coordinates: LngLat {
|
||||||
|
lng: i as f64 * 0.009,
|
||||||
|
lat: 0.0,
|
||||||
|
},
|
||||||
|
time,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timed(range: std::ops::Range<usize>, start: i64, step: i64) -> Vec<Trackpoint> {
|
||||||
|
range
|
||||||
|
.map(|i| point(i, Some(start + step * i as i64)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn untimed(range: std::ops::Range<usize>) -> Vec<Trackpoint> {
|
||||||
|
range.map(|i| point(i, None)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn times(trkpt: &[Trackpoint]) -> Vec<Option<i64>> {
|
||||||
|
trkpt.iter().map(|p| p.time).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_without_times_nor_speed_it_is_a_splice() {
|
||||||
|
let mut trkpt = untimed(0..5);
|
||||||
|
replace_trackpoints(&mut trkpt, 1, 3, untimed(10..13), None, None, false);
|
||||||
|
assert_eq!(trkpt.len(), 6);
|
||||||
|
assert!(trkpt.iter().all(|p| p.time.is_none()));
|
||||||
|
assert_eq!(trkpt[1].coordinates.lng, point(10, None).coordinates.lng);
|
||||||
|
assert_eq!(trkpt[4].coordinates.lng, point(3, None).coordinates.lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_points_follow_the_previous_one_at_the_speed() {
|
||||||
|
// 1 km in 10 s is 360 km/h
|
||||||
|
let mut trkpt = timed(0..4, 0, 10_000);
|
||||||
|
let km = distance(point(0, None).coordinates, point(1, None).coordinates);
|
||||||
|
let speed = 36.0 * km / 0.01; // the time of 1 point to the next is 100 s
|
||||||
|
replace_trackpoints(&mut trkpt, 2, 3, untimed(2..4), Some(speed), None, false);
|
||||||
|
let expected = 10_000 + (3_600_000.0 * km / speed) as i64;
|
||||||
|
assert_eq!(trkpt[2].time, Some(expected));
|
||||||
|
assert!(trkpt[2].time > trkpt[1].time);
|
||||||
|
// the points after follow the new ones, they were not too early
|
||||||
|
assert_eq!(trkpt.len(), 5);
|
||||||
|
assert_eq!(trkpt[4].time, Some(30_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_without_a_speed_new_points_are_one_second_apart() {
|
||||||
|
let mut trkpt = timed(0..3, 5_000, 60_000);
|
||||||
|
replace_trackpoints(&mut trkpt, 1, 2, untimed(10..13), None, None, false);
|
||||||
|
assert_eq!(
|
||||||
|
times(&trkpt[..4]),
|
||||||
|
[Some(5_000), Some(6_000), Some(7_000), Some(8_000)]
|
||||||
|
);
|
||||||
|
// what comes after was after, it is kept
|
||||||
|
assert_eq!(trkpt[4].time, Some(125_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_points_after_are_shifted_if_they_are_too_early() {
|
||||||
|
let mut trkpt = timed(0..4, 0, 10_000);
|
||||||
|
// 30 new points, one second apart: the last one is at 30 s, but the next original
|
||||||
|
// trackpoint is at 20 s
|
||||||
|
replace_trackpoints(&mut trkpt, 1, 2, untimed(10..40), None, None, false);
|
||||||
|
assert_eq!(trkpt.len(), 33);
|
||||||
|
assert_eq!(trkpt[1].time, Some(1_000));
|
||||||
|
assert_eq!(trkpt[30].time, Some(30_000));
|
||||||
|
// the following points keep the duration between them, after the new ones
|
||||||
|
assert_eq!(trkpt[31].time, Some(31_000));
|
||||||
|
assert_eq!(trkpt[32].time, Some(41_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_path_without_times_gets_them_from_the_speed_and_start_time() {
|
||||||
|
let mut trkpt = untimed(0..4);
|
||||||
|
replace_trackpoints(
|
||||||
|
&mut trkpt,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
untimed(3..6),
|
||||||
|
Some(10.0),
|
||||||
|
Some(1_000),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let times: Vec<_> = times(&trkpt);
|
||||||
|
assert_eq!(times[0], Some(1_000));
|
||||||
|
assert!(times.iter().all(Option::is_some));
|
||||||
|
assert!(times.windows(2).all(|w| w[0].unwrap() < w[1].unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_an_unusable_speed_is_as_if_there_was_none() {
|
||||||
|
for speed in [0.0, -5.0, f64::NAN, f64::INFINITY] {
|
||||||
|
let mut trkpt = timed(0..2, 0, 10_000);
|
||||||
|
replace_trackpoints(&mut trkpt, 2, 2, untimed(2..4), Some(speed), None, false);
|
||||||
|
assert_eq!(
|
||||||
|
times(&trkpt),
|
||||||
|
[Some(0), Some(10_000), Some(11_000), Some(12_000)],
|
||||||
|
"{speed}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_points_with_times_that_are_too_early_are_shifted() {
|
||||||
|
let mut trkpt = timed(0..3, 100_000, 10_000);
|
||||||
|
// they are at 0, 5 s: they are moved to follow the trackpoint at 110 s
|
||||||
|
let new = vec![point(5, Some(0)), point(6, Some(5_000))];
|
||||||
|
replace_trackpoints(&mut trkpt, 2, 3, new, None, None, false);
|
||||||
|
// they follow the previous trackpoint after one second, keeping their own durations
|
||||||
|
assert_eq!(trkpt[2].time, Some(111_000));
|
||||||
|
assert_eq!(trkpt[3].time, Some(116_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove_gaps_at_the_same_place_and_elsewhere() {
|
||||||
|
// same place: the first new point is the previous trackpoint
|
||||||
|
let mut trkpt = timed(0..3, 0, 10_000);
|
||||||
|
let new = vec![point(2, Some(500_000)), point(3, Some(510_000))];
|
||||||
|
replace_trackpoints(&mut trkpt, 3, 3, new.clone(), None, None, true);
|
||||||
|
assert_eq!(trkpt.len(), 4);
|
||||||
|
// without a speed, it is one second after the previous trackpoint, not at its time
|
||||||
|
assert_eq!(trkpt[3].time, Some(31_000));
|
||||||
|
// with a speed, the distance is zero
|
||||||
|
let mut trkpt = timed(0..3, 0, 10_000);
|
||||||
|
replace_trackpoints(&mut trkpt, 3, 3, new, Some(10.0), None, true);
|
||||||
|
assert_eq!(trkpt[3].time, Some(30_000));
|
||||||
|
|
||||||
|
// another place: one second after the previous one
|
||||||
|
let mut trkpt = timed(0..3, 0, 10_000);
|
||||||
|
let new = vec![point(5, Some(500_000)), point(6, Some(510_000))];
|
||||||
|
replace_trackpoints(&mut trkpt, 3, 3, new, None, None, true);
|
||||||
|
assert_eq!(trkpt.len(), 5);
|
||||||
|
// the old code adds the second twice with no speed, see `timestamp_after`
|
||||||
|
assert_eq!(trkpt[3].time, Some(22_000));
|
||||||
|
assert_eq!(trkpt[4].time, Some(32_000));
|
||||||
|
|
||||||
|
// without removing the gaps they stay
|
||||||
|
let mut trkpt = timed(0..3, 0, 10_000);
|
||||||
|
let new = vec![point(5, Some(500_000)), point(6, Some(510_000))];
|
||||||
|
replace_trackpoints(&mut trkpt, 3, 3, new, None, None, false);
|
||||||
|
assert_eq!(trkpt[3].time, Some(500_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_removing_points_adapts_nothing_if_the_next_one_is_after() {
|
||||||
|
let mut trkpt = timed(0..5, 0, 10_000);
|
||||||
|
replace_trackpoints(&mut trkpt, 1, 3, vec![], None, None, false);
|
||||||
|
assert_eq!(times(&trkpt), [Some(0), Some(30_000), Some(40_000)]);
|
||||||
|
// from the beginning
|
||||||
|
replace_trackpoints(&mut trkpt, 0, 2, vec![], None, None, false);
|
||||||
|
assert_eq!(times(&trkpt), [Some(40_000)]);
|
||||||
|
replace_trackpoints(&mut trkpt, 0, 1, vec![], None, None, false);
|
||||||
|
assert!(trkpt.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_points_without_a_known_previous_time_get_none() {
|
||||||
|
// nothing gives a time to start with
|
||||||
|
let mut trkpt = untimed(0..2);
|
||||||
|
replace_trackpoints(&mut trkpt, 2, 2, untimed(2..4), Some(10.0), None, false);
|
||||||
|
assert!(trkpt.iter().all(|p| p.time.is_none()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/// Maps the values of a category of trackpoint data (the surface, the highway...) to small codes,
|
||||||
|
/// so that the trackpoints only hold a `u8` instead of a string.
|
||||||
|
///
|
||||||
|
/// The codes are the positions of the names in the order of first appearance. The table only
|
||||||
|
/// grows: a code never changes meaning, so it stays valid in every state of the history.
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct Categories(Vec<String>);
|
||||||
|
|
||||||
|
impl Categories {
|
||||||
|
/// Maximum number of distinct values.
|
||||||
|
pub const CAPACITY: usize = u8::MAX as usize;
|
||||||
|
|
||||||
|
/// The code of `name`, which is added to the table if it is new. `None` if the table is full.
|
||||||
|
pub fn code(&mut self, name: &str) -> Option<u8> {
|
||||||
|
let index = match self.0.iter().position(|known| known == name) {
|
||||||
|
Some(index) => index,
|
||||||
|
None if self.0.len() < Self::CAPACITY => {
|
||||||
|
self.0.push(name.to_owned());
|
||||||
|
self.0.len() - 1
|
||||||
|
}
|
||||||
|
None => return None,
|
||||||
|
};
|
||||||
|
u8::try_from(index).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name of a code, `None` if it is unknown.
|
||||||
|
pub fn name(&self, code: u8) -> Option<&str> {
|
||||||
|
self.0.get(usize::from(code)).map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All the names, in the order of their codes.
|
||||||
|
pub fn names(&self) -> &[String] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The categories of the data of the trackpoints. There is one table per engine, shared by all
|
||||||
|
/// its files.
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct TrackpointCategories {
|
||||||
|
pub surface: Categories,
|
||||||
|
pub highway: Categories,
|
||||||
|
pub sac_scale: Categories,
|
||||||
|
pub mtb_scale: Categories,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_codes_follow_the_order_of_appearance() {
|
||||||
|
let mut categories = Categories::default();
|
||||||
|
assert_eq!(categories.code("asphalt"), Some(0));
|
||||||
|
assert_eq!(categories.code("gravel"), Some(1));
|
||||||
|
// a known name keeps its code
|
||||||
|
assert_eq!(categories.code("asphalt"), Some(0));
|
||||||
|
assert_eq!(categories.names(), ["asphalt", "gravel"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_names_of_codes() {
|
||||||
|
let mut categories = Categories::default();
|
||||||
|
let code = categories.code("asphalt").unwrap();
|
||||||
|
assert_eq!(categories.name(code), Some("asphalt"));
|
||||||
|
assert_eq!(categories.name(code + 1), None);
|
||||||
|
assert_eq!(Categories::default().name(0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_full_table_gives_no_code_to_new_names() {
|
||||||
|
let mut categories = Categories::default();
|
||||||
|
for i in 0..Categories::CAPACITY {
|
||||||
|
assert_eq!(categories.code(&i.to_string()), Some(i as u8));
|
||||||
|
}
|
||||||
|
assert_eq!(categories.code("one too many"), None);
|
||||||
|
assert_eq!(categories.names().len(), Categories::CAPACITY);
|
||||||
|
// the known ones still have their code, the last one included
|
||||||
|
assert_eq!(categories.code("0"), Some(0));
|
||||||
|
assert_eq!(categories.code("254"), Some(254));
|
||||||
|
assert_eq!(categories.name(254), Some("254"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_categories_are_independent() {
|
||||||
|
let mut categories = TrackpointCategories::default();
|
||||||
|
categories.surface.code("asphalt");
|
||||||
|
assert_eq!(categories.highway.code("path"), Some(0));
|
||||||
|
assert_eq!(categories.sac_scale.code("hiking"), Some(0));
|
||||||
|
assert_eq!(categories.mtb_scale.code("1"), Some(0));
|
||||||
|
assert_eq!(categories.surface.names(), ["asphalt"]);
|
||||||
|
assert_eq!(categories.highway.names(), ["path"]);
|
||||||
|
assert_eq!(categories.sac_scale.names(), ["hiking"]);
|
||||||
|
assert_eq!(categories.mtb_scale.names(), ["1"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
use crate::{Trackpoint, Waypoint};
|
||||||
|
|
||||||
|
use super::common::uuid_id;
|
||||||
|
|
||||||
|
/// A run of items, shared between the successive versions of a [`crate::Chunked`] list: a chunk
|
||||||
|
/// that is not touched by an edit is not copied.
|
||||||
|
///
|
||||||
|
/// A chunk has an identity, which changes whenever the content does (a modified chunk is a new
|
||||||
|
/// one): what is derived from a chunk, or kept of it, is up to date as long as the identity is.
|
||||||
|
pub trait Chunk {
|
||||||
|
type Item: Clone;
|
||||||
|
|
||||||
|
/// Maximum number of items of a chunk.
|
||||||
|
const MAX_SIZE: usize;
|
||||||
|
|
||||||
|
/// A chunk with a new identity, holding `items`.
|
||||||
|
fn new(items: Vec<Self::Item>) -> Self;
|
||||||
|
|
||||||
|
/// Gives the chunk a new identity, after its items were changed in place.
|
||||||
|
fn renew(&mut self);
|
||||||
|
|
||||||
|
fn items(&self) -> &Vec<Self::Item>;
|
||||||
|
|
||||||
|
fn items_mut(&mut self) -> &mut Vec<Self::Item>;
|
||||||
|
|
||||||
|
fn is_full(&self) -> bool {
|
||||||
|
self.items().len() >= Self::MAX_SIZE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TRKPT_CHUNK_SIZE: usize = 4096;
|
||||||
|
|
||||||
|
uuid_id!(TrackpointChunkId);
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct TrackpointChunk {
|
||||||
|
pub id: TrackpointChunkId,
|
||||||
|
pub trkpt: Vec<Trackpoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Chunk for TrackpointChunk {
|
||||||
|
type Item = Trackpoint;
|
||||||
|
const MAX_SIZE: usize = MAX_TRKPT_CHUNK_SIZE;
|
||||||
|
|
||||||
|
fn new(trkpt: Vec<Trackpoint>) -> Self {
|
||||||
|
Self {
|
||||||
|
trkpt,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renew(&mut self) {
|
||||||
|
self.id = Default::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn items(&self) -> &Vec<Trackpoint> {
|
||||||
|
&self.trkpt
|
||||||
|
}
|
||||||
|
|
||||||
|
fn items_mut(&mut self) -> &mut Vec<Trackpoint> {
|
||||||
|
&mut self.trkpt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uuid_id!(WaypointChunkId);
|
||||||
|
|
||||||
|
const MAX_WPT_CHUNK_SIZE: usize = 128;
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct WaypointChunk {
|
||||||
|
pub id: WaypointChunkId,
|
||||||
|
pub wpt: Vec<Waypoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Chunk for WaypointChunk {
|
||||||
|
type Item = Waypoint;
|
||||||
|
const MAX_SIZE: usize = MAX_WPT_CHUNK_SIZE;
|
||||||
|
|
||||||
|
fn new(wpt: Vec<Waypoint>) -> Self {
|
||||||
|
Self {
|
||||||
|
wpt,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renew(&mut self) {
|
||||||
|
self.id = Default::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn items(&self) -> &Vec<Waypoint> {
|
||||||
|
&self.wpt
|
||||||
|
}
|
||||||
|
|
||||||
|
fn items_mut(&mut self) -> &mut Vec<Waypoint> {
|
||||||
|
&mut self.wpt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trackpoint_chunk_full() {
|
||||||
|
let mut chunk = TrackpointChunk::default();
|
||||||
|
assert!(!chunk.is_full());
|
||||||
|
for _ in 0..MAX_TRKPT_CHUNK_SIZE - 1 {
|
||||||
|
chunk.trkpt.push(Trackpoint::default());
|
||||||
|
}
|
||||||
|
assert!(!chunk.is_full());
|
||||||
|
chunk.trkpt.push(Trackpoint::default());
|
||||||
|
assert!(chunk.is_full());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_waypoint_chunk_full() {
|
||||||
|
let mut chunk = WaypointChunk::default();
|
||||||
|
assert!(!chunk.is_full());
|
||||||
|
for _ in 0..MAX_WPT_CHUNK_SIZE {
|
||||||
|
chunk.wpt.push(Waypoint::default());
|
||||||
|
}
|
||||||
|
assert!(chunk.is_full());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_chunks_have_their_own_identity() {
|
||||||
|
let a = TrackpointChunk::new(vec![Trackpoint::default()]);
|
||||||
|
let b = TrackpointChunk::new(vec![Trackpoint::default()]);
|
||||||
|
assert_ne!(a.id, b.id);
|
||||||
|
assert_eq!(a.items().len(), 1);
|
||||||
|
assert_ne!(WaypointChunk::new(vec![]).id, WaypointChunk::new(vec![]).id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
use std::{ops::Index, rc::Rc};
|
||||||
|
|
||||||
|
use crate::Chunk;
|
||||||
|
|
||||||
|
/// A list of items, stored in [`Chunk`]s that are shared between its versions: editing it only
|
||||||
|
/// copies the chunks around the change, so that a version costs little more than the change.
|
||||||
|
///
|
||||||
|
/// There are no empty chunks.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Chunked<C: Chunk> {
|
||||||
|
chunks: Vec<Rc<C>>,
|
||||||
|
cumul_length: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: Chunk> Clone for Chunked<C> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
chunks: self.chunks.clone(),
|
||||||
|
cumul_length: self.cumul_length.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: Chunk> Default for Chunked<C> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
chunks: vec![],
|
||||||
|
cumul_length: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The position of an item: in which chunk, where in it, and among all the items.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord)]
|
||||||
|
pub struct ChunkIndex {
|
||||||
|
pub chunk: usize,
|
||||||
|
pub pos: usize,
|
||||||
|
pub flat: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: Chunk> Chunked<C> {
|
||||||
|
/// Adds a chunk at the end, which is dropped if it is empty.
|
||||||
|
pub fn push(&mut self, chunk: C) {
|
||||||
|
self.push_shared(Rc::new(chunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_shared(&mut self, chunk: Rc<C>) {
|
||||||
|
if chunk.items().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.cumul_length
|
||||||
|
.push(self.cumul_length.last().copied().unwrap_or_default() + chunk.items().len());
|
||||||
|
self.chunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chunks(&self) -> &[Rc<C>] {
|
||||||
|
&self.chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the items in `start..end` by `items`. Panics if the range is out of bounds.
|
||||||
|
///
|
||||||
|
/// Chunks that are not concerned are kept as they are (shared), only the chunks around the
|
||||||
|
/// range are copied and refilled.
|
||||||
|
pub fn splice(&mut self, start: usize, end: usize, items: Vec<C::Item>) {
|
||||||
|
assert!(
|
||||||
|
start <= end && end <= self.len(),
|
||||||
|
"splice range out of bounds"
|
||||||
|
);
|
||||||
|
let old = std::mem::take(&mut self.chunks);
|
||||||
|
self.cumul_length.clear();
|
||||||
|
|
||||||
|
let mut items = Some(items);
|
||||||
|
let mut pending = vec![];
|
||||||
|
let mut offset = 0;
|
||||||
|
for chunk in old {
|
||||||
|
let (lo, hi) = (offset, offset + chunk.items().len());
|
||||||
|
offset = hi;
|
||||||
|
// a chunk ending at `start` is extended unless it is full, to avoid tiny chunks
|
||||||
|
if hi < start || (hi == start && chunk.is_full()) {
|
||||||
|
self.push_shared(chunk);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(items) = items.take() {
|
||||||
|
self.fill(&mut pending, chunk.items()[..start - lo].iter().cloned());
|
||||||
|
self.fill(&mut pending, items);
|
||||||
|
}
|
||||||
|
if hi <= end {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if lo >= end {
|
||||||
|
self.flush(&mut pending);
|
||||||
|
self.push_shared(chunk);
|
||||||
|
} else {
|
||||||
|
self.fill(&mut pending, chunk.items()[end - lo..].iter().cloned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(items) = items {
|
||||||
|
self.fill(&mut pending, items);
|
||||||
|
}
|
||||||
|
self.flush(&mut pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill(&mut self, pending: &mut Vec<C::Item>, items: impl IntoIterator<Item = C::Item>) {
|
||||||
|
for item in items {
|
||||||
|
pending.push(item);
|
||||||
|
if pending.len() >= C::MAX_SIZE {
|
||||||
|
self.flush(pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self, pending: &mut Vec<C::Item>) {
|
||||||
|
self.push(C::new(std::mem::take(pending)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edits the items of the chunks that contain an item accepted by `filter`.
|
||||||
|
///
|
||||||
|
/// `f` receives a copy of the items of such a chunk and returns whether it changed them. A
|
||||||
|
/// changed chunk is replaced by a new one (so that what is derived from it is computed
|
||||||
|
/// again) and the chunks left empty are dropped. The other chunks are kept. Returns whether
|
||||||
|
/// anything changed.
|
||||||
|
pub fn edit(
|
||||||
|
&mut self,
|
||||||
|
filter: impl Fn(&C::Item) -> bool,
|
||||||
|
mut f: impl FnMut(&mut Vec<C::Item>) -> bool,
|
||||||
|
) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
let old = std::mem::take(&mut self.chunks);
|
||||||
|
self.cumul_length.clear();
|
||||||
|
for chunk in old {
|
||||||
|
if !chunk.items().iter().any(&filter) {
|
||||||
|
self.push_shared(chunk);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut items = chunk.items().clone();
|
||||||
|
if !f(&mut items) {
|
||||||
|
self.push_shared(chunk);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
self.push(C::new(items));
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes what is in the chunk `chunk`, but not how many items there are. A chunk that is
|
||||||
|
/// shared is copied first, so that the other versions do not change.
|
||||||
|
fn replace_chunk(&mut self, chunk: usize, f: impl FnOnce(&mut Vec<C::Item>)) {
|
||||||
|
let len = self.chunks[chunk].items().len();
|
||||||
|
match Rc::get_mut(&mut self.chunks[chunk]) {
|
||||||
|
Some(unique) => {
|
||||||
|
f(unique.items_mut());
|
||||||
|
unique.renew();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut items = self.chunks[chunk].items().clone();
|
||||||
|
f(&mut items);
|
||||||
|
self.chunks[chunk] = Rc::new(C::new(items));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug_assert_eq!(self.chunks[chunk].items().len(), len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies `f` to the item at `index`, which is copied in a new chunk. Panics if there is no
|
||||||
|
/// such item.
|
||||||
|
pub fn update(&mut self, index: usize, f: impl FnOnce(&mut C::Item)) {
|
||||||
|
let ChunkIndex { chunk, pos, .. } = self.locate(index).unwrap();
|
||||||
|
self.replace_chunk(chunk, |items| f(&mut items[pos]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies `f` to every item, with its index, in new chunks.
|
||||||
|
pub fn update_all(&mut self, mut f: impl FnMut(usize, &mut C::Item)) {
|
||||||
|
let mut offset = 0;
|
||||||
|
for chunk in 0..self.chunks.len() {
|
||||||
|
let len = self.chunks[chunk].items().len();
|
||||||
|
self.replace_chunk(chunk, |items| {
|
||||||
|
for (i, item) in items.iter_mut().enumerate() {
|
||||||
|
f(offset + i, item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
offset += len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.cumul_length.last().copied().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.chunks.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> ChunkedIter<'_, C> {
|
||||||
|
ChunkedIter::new(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn first_index(&self) -> Option<ChunkIndex> {
|
||||||
|
self.next_index(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last_index(&self) -> Option<ChunkIndex> {
|
||||||
|
self.prev_index(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_index(&self, cur: Option<ChunkIndex>) -> Option<ChunkIndex> {
|
||||||
|
let mut next = cur.map_or_default(|idx| ChunkIndex {
|
||||||
|
chunk: idx.chunk,
|
||||||
|
pos: idx.pos + 1,
|
||||||
|
flat: idx.flat + 1,
|
||||||
|
});
|
||||||
|
loop {
|
||||||
|
if next.chunk >= self.chunks.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if next.pos == self.chunks[next.chunk].items().len() {
|
||||||
|
next.chunk += 1;
|
||||||
|
next.pos = 0;
|
||||||
|
} else {
|
||||||
|
return Some(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_index(&self, cur: Option<ChunkIndex>) -> Option<ChunkIndex> {
|
||||||
|
let mut prev = cur.unwrap_or(ChunkIndex {
|
||||||
|
chunk: self.chunks.len(),
|
||||||
|
pos: 0,
|
||||||
|
flat: self.cumul_length.last().copied().unwrap_or_default(),
|
||||||
|
});
|
||||||
|
if prev.pos == 0 {
|
||||||
|
while prev.chunk > 0 {
|
||||||
|
prev.chunk -= 1;
|
||||||
|
if !self.chunks[prev.chunk].items().is_empty() {
|
||||||
|
prev.pos = self.chunks[prev.chunk].items().len() - 1;
|
||||||
|
prev.flat -= 1;
|
||||||
|
return Some(prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
prev.pos -= 1;
|
||||||
|
prev.flat -= 1;
|
||||||
|
Some(prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The position of the item `idx` among all the items.
|
||||||
|
pub fn locate(&self, idx: usize) -> Option<ChunkIndex> {
|
||||||
|
let chunk = self.cumul_length.partition_point(|l| idx >= *l);
|
||||||
|
if chunk >= self.chunks.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let pos = if chunk > 0 {
|
||||||
|
idx - self.cumul_length[chunk - 1]
|
||||||
|
} else {
|
||||||
|
idx
|
||||||
|
};
|
||||||
|
if pos >= self.chunks[chunk].items().len() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(ChunkIndex {
|
||||||
|
chunk,
|
||||||
|
pos,
|
||||||
|
flat: idx,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: Chunk> Index<ChunkIndex> for Chunked<C> {
|
||||||
|
type Output = C::Item;
|
||||||
|
|
||||||
|
fn index(&self, idx: ChunkIndex) -> &Self::Output {
|
||||||
|
&self.chunks[idx.chunk].items()[idx.pos]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: Chunk> Index<usize> for Chunked<C> {
|
||||||
|
type Output = C::Item;
|
||||||
|
|
||||||
|
fn index(&self, idx: usize) -> &Self::Output {
|
||||||
|
&self[self.locate(idx).unwrap()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, C: Chunk> IntoIterator for &'a Chunked<C> {
|
||||||
|
type Item = &'a C::Item;
|
||||||
|
type IntoIter = ChunkedIter<'a, C>;
|
||||||
|
|
||||||
|
fn into_iter(self) -> Self::IntoIter {
|
||||||
|
self.iter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ChunkedIter<'a, C: Chunk> {
|
||||||
|
chunked: &'a Chunked<C>,
|
||||||
|
/// The item returned last.
|
||||||
|
idx: Option<ChunkIndex>,
|
||||||
|
exhausted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: Chunk> Clone for ChunkedIter<'_, C> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
chunked: self.chunked,
|
||||||
|
idx: self.idx,
|
||||||
|
exhausted: self.exhausted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, C: Chunk> ChunkedIter<'a, C> {
|
||||||
|
pub fn new(chunked: &'a Chunked<C>) -> Self {
|
||||||
|
Self {
|
||||||
|
chunked,
|
||||||
|
idx: None,
|
||||||
|
exhausted: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_to(&mut self, idx: Option<ChunkIndex>) -> Option<&'a C::Item> {
|
||||||
|
self.exhausted = idx.is_none();
|
||||||
|
self.idx = idx.or(self.idx);
|
||||||
|
idx.map(|idx| &self.chunked[idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, C: Chunk> Iterator for ChunkedIter<'a, C> {
|
||||||
|
type Item = &'a C::Item;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
if self.exhausted {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let next = self.chunked.next_index(self.idx);
|
||||||
|
self.move_to(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||||
|
if self.exhausted {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let target = self.idx.map_or(0, |idx| idx.flat + 1).saturating_add(n);
|
||||||
|
let next = self.chunked.locate(target);
|
||||||
|
self.move_to(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Chunks of at most 3 numbers, with an identity like the real ones.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Numbers {
|
||||||
|
id: usize,
|
||||||
|
items: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static NEXT_ID: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_id() -> usize {
|
||||||
|
NEXT_ID.with(|id| id.replace(id.get() + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Chunk for Numbers {
|
||||||
|
type Item = u32;
|
||||||
|
const MAX_SIZE: usize = 3;
|
||||||
|
|
||||||
|
fn new(items: Vec<u32>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: next_id(),
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renew(&mut self) {
|
||||||
|
self.id = next_id();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn items(&self) -> &Vec<u32> {
|
||||||
|
&self.items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn items_mut(&mut self) -> &mut Vec<u32> {
|
||||||
|
&mut self.items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The numbers `0..n` in chunks of 3.
|
||||||
|
fn numbers(n: u32) -> Chunked<Numbers> {
|
||||||
|
let mut chunked = Chunked::default();
|
||||||
|
for start in (0..n).step_by(3) {
|
||||||
|
chunked.push(Numbers::new((start..(start + 3).min(n)).collect()));
|
||||||
|
}
|
||||||
|
chunked
|
||||||
|
}
|
||||||
|
|
||||||
|
fn values(chunked: &Chunked<Numbers>) -> Vec<u32> {
|
||||||
|
chunked.iter().copied().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ids(chunked: &Chunked<Numbers>) -> Vec<usize> {
|
||||||
|
chunked.chunks().iter().map(|chunk| chunk.id).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_consistent(chunked: &Chunked<Numbers>) {
|
||||||
|
let sizes: Vec<usize> = chunked.chunks().iter().map(|c| c.items.len()).collect();
|
||||||
|
assert!(
|
||||||
|
sizes
|
||||||
|
.iter()
|
||||||
|
.all(|&size| 0 < size && size <= Numbers::MAX_SIZE)
|
||||||
|
);
|
||||||
|
assert_eq!(chunked.len(), sizes.iter().sum::<usize>());
|
||||||
|
for index in 0..chunked.len() {
|
||||||
|
assert_eq!(chunked.locate(index).unwrap().flat, index);
|
||||||
|
}
|
||||||
|
assert!(chunked.locate(chunked.len()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_push_drops_empty_chunks() {
|
||||||
|
let mut chunked = numbers(4);
|
||||||
|
chunked.push(Numbers::new(vec![]));
|
||||||
|
assert_eq!(chunked.chunks().len(), 2);
|
||||||
|
assert_eq!(chunked.len(), 4);
|
||||||
|
assert!(Chunked::<Numbers>::default().is_empty());
|
||||||
|
assert!(!chunked.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_locate_index_and_iterate() {
|
||||||
|
let chunked = numbers(7);
|
||||||
|
assert_eq!(
|
||||||
|
chunked.locate(4),
|
||||||
|
Some(ChunkIndex {
|
||||||
|
chunk: 1,
|
||||||
|
pos: 1,
|
||||||
|
flat: 4
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(chunked[4], 4);
|
||||||
|
assert_eq!(chunked[chunked.locate(6).unwrap()], 6);
|
||||||
|
assert!(chunked.locate(7).is_none());
|
||||||
|
assert_eq!(values(&chunked), (0..7).collect::<Vec<_>>());
|
||||||
|
assert_eq!(chunked.iter().count(), 7);
|
||||||
|
assert_eq!((&chunked).into_iter().last(), Some(&6));
|
||||||
|
assert_eq!(values(&Chunked::default()), Vec::<u32>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_first_last_next_and_previous_index() {
|
||||||
|
let chunked = numbers(7);
|
||||||
|
let first = chunked.first_index().unwrap();
|
||||||
|
let last = chunked.last_index().unwrap();
|
||||||
|
assert_eq!((first.flat, last.flat), (0, 6));
|
||||||
|
assert_eq!((last.chunk, last.pos), (2, 0));
|
||||||
|
|
||||||
|
let mut forward = vec![];
|
||||||
|
let mut cur = None;
|
||||||
|
while let Some(next) = chunked.next_index(cur) {
|
||||||
|
forward.push(chunked[next]);
|
||||||
|
cur = Some(next);
|
||||||
|
}
|
||||||
|
assert_eq!(forward, (0..7).collect::<Vec<_>>());
|
||||||
|
|
||||||
|
let mut backward = vec![];
|
||||||
|
let mut cur = None;
|
||||||
|
while let Some(prev) = chunked.prev_index(cur) {
|
||||||
|
backward.push(chunked[prev]);
|
||||||
|
cur = Some(prev);
|
||||||
|
}
|
||||||
|
assert_eq!(backward, (0..7).rev().collect::<Vec<_>>());
|
||||||
|
|
||||||
|
let empty = Chunked::<Numbers>::default();
|
||||||
|
assert!(empty.first_index().is_none() && empty.last_index().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::iter_nth_zero)]
|
||||||
|
fn test_iterator_nth_goes_on_from_the_current_item() {
|
||||||
|
let chunked = numbers(10);
|
||||||
|
let mut iter = chunked.iter();
|
||||||
|
assert_eq!(iter.nth(1), Some(&1));
|
||||||
|
assert_eq!(iter.next(), Some(&2));
|
||||||
|
// skips 3 and 4
|
||||||
|
assert_eq!(iter.nth(2), Some(&5));
|
||||||
|
assert_eq!(iter.nth(0), Some(&6));
|
||||||
|
assert_eq!(iter.nth(2), Some(&9));
|
||||||
|
assert_eq!(iter.nth(0), None);
|
||||||
|
assert_eq!(iter.next(), None, "an exhausted iterator stays exhausted");
|
||||||
|
|
||||||
|
assert_eq!(values(&numbers(10)).iter().skip(4).count(), 6);
|
||||||
|
assert_eq!(
|
||||||
|
chunked.iter().skip(4).copied().collect::<Vec<_>>(),
|
||||||
|
(4..10).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
chunked.iter().step_by(4).copied().collect::<Vec<_>>(),
|
||||||
|
vec![0, 4, 8]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_splice_replaces_inserts_deletes_and_appends() {
|
||||||
|
let mut chunked = numbers(10);
|
||||||
|
chunked.splice(2, 5, vec![100, 101]);
|
||||||
|
assert_eq!(values(&chunked), [0, 1, 100, 101, 5, 6, 7, 8, 9]);
|
||||||
|
assert_consistent(&chunked);
|
||||||
|
|
||||||
|
chunked.splice(0, 0, vec![50]);
|
||||||
|
assert_eq!(values(&chunked)[..3], [50, 0, 1]);
|
||||||
|
chunked.splice(chunked.len(), chunked.len(), vec![7, 7, 7, 7]);
|
||||||
|
assert_eq!(values(&chunked)[chunked.len() - 5..], [9, 7, 7, 7, 7]);
|
||||||
|
assert_consistent(&chunked);
|
||||||
|
|
||||||
|
let len = chunked.len();
|
||||||
|
chunked.splice(0, len, vec![]);
|
||||||
|
assert!(chunked.is_empty());
|
||||||
|
assert_eq!(chunked.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "splice range out of bounds")]
|
||||||
|
fn test_splice_out_of_bounds_panics() {
|
||||||
|
numbers(4).splice(2, 5, vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_splice_keeps_the_chunks_it_does_not_touch() {
|
||||||
|
let mut chunked = numbers(12);
|
||||||
|
let before = ids(&chunked);
|
||||||
|
// inside the second chunk
|
||||||
|
chunked.splice(4, 5, vec![40]);
|
||||||
|
let after = ids(&chunked);
|
||||||
|
assert_eq!(after.len(), before.len());
|
||||||
|
assert_eq!(
|
||||||
|
(after[0], after[2], after[3]),
|
||||||
|
(before[0], before[2], before[3])
|
||||||
|
);
|
||||||
|
assert_ne!(after[1], before[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_replaces_only_the_changed_chunks() {
|
||||||
|
let mut chunked = numbers(9);
|
||||||
|
let before = ids(&chunked);
|
||||||
|
|
||||||
|
// nothing is changed: nothing is replaced
|
||||||
|
assert!(!chunked.edit(|n| *n == 4, |_| false));
|
||||||
|
assert_eq!(ids(&chunked), before);
|
||||||
|
|
||||||
|
// the filter picks the chunk, the hook changes it
|
||||||
|
assert!(chunked.edit(
|
||||||
|
|n| *n == 4,
|
||||||
|
|items| {
|
||||||
|
items[1] = 400;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
));
|
||||||
|
let after = ids(&chunked);
|
||||||
|
assert_eq!((after[0], after[2]), (before[0], before[2]));
|
||||||
|
assert_ne!(after[1], before[1]);
|
||||||
|
assert_eq!(values(&chunked), [0, 1, 2, 3, 400, 5, 6, 7, 8]);
|
||||||
|
|
||||||
|
// a chunk left empty is dropped
|
||||||
|
assert!(chunked.edit(
|
||||||
|
|n| *n == 0,
|
||||||
|
|items| {
|
||||||
|
items.clear();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
));
|
||||||
|
assert_eq!(chunked.chunks().len(), 2);
|
||||||
|
assert_consistent(&chunked);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_copies_the_chunk_so_that_other_versions_do_not_change() {
|
||||||
|
let mut chunked = numbers(6);
|
||||||
|
let version = chunked.clone();
|
||||||
|
let before = ids(&chunked);
|
||||||
|
chunked.update(4, |n| *n = 40);
|
||||||
|
assert_eq!(values(&chunked), [0, 1, 2, 3, 40, 5]);
|
||||||
|
assert_eq!(values(&version), (0..6).collect::<Vec<_>>());
|
||||||
|
assert_eq!(ids(&version), before);
|
||||||
|
assert_eq!(ids(&chunked)[0], before[0]);
|
||||||
|
assert_ne!(ids(&chunked)[1], before[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_updated_chunks_get_a_new_identity_even_when_nothing_shares_them() {
|
||||||
|
// what is derived from or kept of a chunk is keyed by its identity
|
||||||
|
let mut chunked = numbers(6);
|
||||||
|
let before = ids(&chunked);
|
||||||
|
chunked.update(1, |n| *n += 1);
|
||||||
|
assert_ne!(ids(&chunked)[0], before[0]);
|
||||||
|
assert_eq!(ids(&chunked)[1], before[1]);
|
||||||
|
|
||||||
|
let before = ids(&chunked);
|
||||||
|
chunked.update_all(|_, n| *n += 1);
|
||||||
|
let after = ids(&chunked);
|
||||||
|
assert!(before.iter().zip(&after).all(|(a, b)| a != b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_all_gives_the_index_of_each_item() {
|
||||||
|
let mut chunked = numbers(8);
|
||||||
|
let mut seen = vec![];
|
||||||
|
chunked.update_all(|index, n| {
|
||||||
|
seen.push(index);
|
||||||
|
*n = 10 * index as u32;
|
||||||
|
});
|
||||||
|
assert_eq!(seen, (0..8).collect::<Vec<_>>());
|
||||||
|
assert_eq!(values(&chunked), [0, 10, 20, 30, 40, 50, 60, 70]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/// Defines an identifier made of a UUID, which is a new one by default.
|
||||||
|
macro_rules! uuid_id {
|
||||||
|
($(#[$meta:meta])* $name:ident) => {
|
||||||
|
$(#[$meta])*
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct $name(pub uuid::Uuid);
|
||||||
|
|
||||||
|
impl Default for $name {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(uuid::Uuid::new_v4())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) use uuid_id;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Link {
|
||||||
|
pub href: String,
|
||||||
|
pub text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct LngLat {
|
||||||
|
pub lng: f64,
|
||||||
|
pub lat: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct LngLatBounds {
|
||||||
|
pub sw: LngLat,
|
||||||
|
pub ne: LngLat,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LngLatBounds {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
sw: LngLat {
|
||||||
|
lng: 180.0,
|
||||||
|
lat: 90.0,
|
||||||
|
},
|
||||||
|
ne: LngLat {
|
||||||
|
lng: -180.0,
|
||||||
|
lat: -90.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LngLatBounds {
|
||||||
|
pub fn extend(&mut self, coordinates: LngLat) {
|
||||||
|
self.sw.lng = self.sw.lng.min(coordinates.lng);
|
||||||
|
self.sw.lat = self.sw.lat.min(coordinates.lat);
|
||||||
|
self.ne.lng = self.ne.lng.max(coordinates.lng);
|
||||||
|
self.ne.lat = self.ne.lat.max(coordinates.lat);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merge(&mut self, other: &LngLatBounds) {
|
||||||
|
self.sw.lng = self.sw.lng.min(other.sw.lng);
|
||||||
|
self.sw.lat = self.sw.lat.min(other.sw.lat);
|
||||||
|
self.ne.lng = self.ne.lng.max(other.ne.lng);
|
||||||
|
self.ne.lat = self.ne.lat.max(other.ne.lat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bounds_extend() {
|
||||||
|
let mut bounds = LngLatBounds::default();
|
||||||
|
bounds.extend(LngLat {
|
||||||
|
lng: 4.0,
|
||||||
|
lat: 50.0,
|
||||||
|
});
|
||||||
|
assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 50.0));
|
||||||
|
assert_eq!((bounds.ne.lng, bounds.ne.lat), (4.0, 50.0));
|
||||||
|
|
||||||
|
bounds.extend(LngLat {
|
||||||
|
lng: 6.0,
|
||||||
|
lat: 45.0,
|
||||||
|
});
|
||||||
|
bounds.extend(LngLat {
|
||||||
|
lng: 5.0,
|
||||||
|
lat: 48.0,
|
||||||
|
});
|
||||||
|
assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 45.0));
|
||||||
|
assert_eq!((bounds.ne.lng, bounds.ne.lat), (6.0, 50.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bounds_merge() {
|
||||||
|
let mut a = LngLatBounds::default();
|
||||||
|
a.extend(LngLat { lng: 0.0, lat: 0.0 });
|
||||||
|
a.extend(LngLat { lng: 1.0, lat: 1.0 });
|
||||||
|
let mut b = LngLatBounds::default();
|
||||||
|
b.extend(LngLat {
|
||||||
|
lng: -2.0,
|
||||||
|
lat: 0.5,
|
||||||
|
});
|
||||||
|
b.extend(LngLat { lng: 0.5, lat: 3.0 });
|
||||||
|
|
||||||
|
a.merge(&b);
|
||||||
|
assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0));
|
||||||
|
assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0));
|
||||||
|
|
||||||
|
// merging an empty bounds changes nothing
|
||||||
|
a.merge(&LngLatBounds::default());
|
||||||
|
assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0));
|
||||||
|
assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
use crate::{Link, Track, Waypoints};
|
||||||
|
|
||||||
|
use super::common::uuid_id;
|
||||||
|
|
||||||
|
uuid_id!(FileId);
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct File {
|
||||||
|
pub id: FileId,
|
||||||
|
pub info: FileInfo,
|
||||||
|
pub trk: Vec<Track>,
|
||||||
|
pub wpt: Waypoints,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct FileInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub desc: Option<String>,
|
||||||
|
pub author: Option<Author>,
|
||||||
|
pub links: Vec<Link>,
|
||||||
|
pub time: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct Author {
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// `id@domain`, which the file gives as the two attributes of an `email` element.
|
||||||
|
pub email: Option<String>,
|
||||||
|
/// The author has a single link, unlike the other elements.
|
||||||
|
pub link: Option<Link>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
mod categories;
|
||||||
|
mod chunk;
|
||||||
|
mod chunked;
|
||||||
|
mod common;
|
||||||
|
mod file;
|
||||||
|
mod segment;
|
||||||
|
mod track;
|
||||||
|
mod trackpoint;
|
||||||
|
mod waypoint;
|
||||||
|
mod waypoints;
|
||||||
|
|
||||||
|
pub use categories::*;
|
||||||
|
pub use chunk::*;
|
||||||
|
pub use chunked::*;
|
||||||
|
pub use common::*;
|
||||||
|
pub use file::*;
|
||||||
|
pub use segment::*;
|
||||||
|
pub use track::*;
|
||||||
|
pub use trackpoint::*;
|
||||||
|
pub use waypoint::*;
|
||||||
|
pub use waypoints::*;
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
use std::ops::Deref;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{ChunkIndex, Chunked, ChunkedIter, Trackpoint, TrackpointChunk, compute_anchors};
|
||||||
|
|
||||||
|
use super::common::uuid_id;
|
||||||
|
|
||||||
|
uuid_id!(TrackSegmentId);
|
||||||
|
|
||||||
|
uuid_id!(TrackSegmentRevisionId);
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct TrackSegment {
|
||||||
|
pub id: TrackSegmentId,
|
||||||
|
pub rev_id: TrackSegmentRevisionId,
|
||||||
|
points: Chunked<TrackpointChunk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The position of a trackpoint in a segment.
|
||||||
|
pub type TrackSegmentIndex = ChunkIndex;
|
||||||
|
|
||||||
|
pub type TrackSegmentIterator<'a> = ChunkedIter<'a, TrackpointChunk>;
|
||||||
|
|
||||||
|
impl Deref for TrackSegment {
|
||||||
|
type Target = Chunked<TrackpointChunk>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackSegment {
|
||||||
|
/// Adds a chunk after the last trackpoint, which is dropped if it is empty.
|
||||||
|
pub fn push(&mut self, chunk: TrackpointChunk) {
|
||||||
|
self.points.push(chunk);
|
||||||
|
self.rev_id = Default::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a chunk that is shared with something else after the last trackpoint.
|
||||||
|
pub fn push_shared(&mut self, chunk: Rc<TrackpointChunk>) {
|
||||||
|
self.points.push_shared(chunk);
|
||||||
|
self.rev_id = Default::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies `f` to the trackpoint at `index`, see [`Chunked::update`]. Panics if there is no
|
||||||
|
/// such trackpoint.
|
||||||
|
pub fn update(&mut self, index: usize, f: impl FnOnce(&mut Trackpoint)) {
|
||||||
|
self.points.update(index, f);
|
||||||
|
self.rev_id = Default::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies `f` to every trackpoint, with its index, see [`Chunked::update_all`].
|
||||||
|
pub fn update_all(&mut self, f: impl FnMut(usize, &mut Trackpoint)) {
|
||||||
|
self.points.update_all(f);
|
||||||
|
self.rev_id = Default::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the points in `start..end` by `points`, see [`Chunked::splice`]. The first and
|
||||||
|
/// last trackpoints are anchors afterwards.
|
||||||
|
pub fn splice(&mut self, start: usize, end: usize, points: Vec<Trackpoint>) {
|
||||||
|
self.points.splice(start, end, points);
|
||||||
|
self.rev_id = Default::default();
|
||||||
|
self.ensure_end_anchors();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first and the last trackpoints are always anchors, shown at every zoom level.
|
||||||
|
///
|
||||||
|
/// It is done by [`TrackSegment::splice`]; commands that build segments in another way need to
|
||||||
|
/// call it.
|
||||||
|
pub fn ensure_end_anchors(&mut self) {
|
||||||
|
let Some(last) = self.len().checked_sub(1) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for index in [0, last] {
|
||||||
|
if self[index].anchor != Some(0) {
|
||||||
|
self.set_anchor(index, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes the trackpoint at `index` an anchor shown from the map zoom level `zoom`. Panics if
|
||||||
|
/// there is no such trackpoint.
|
||||||
|
pub fn set_anchor(&mut self, index: usize, zoom: u8) {
|
||||||
|
self.update(index, |trkpt| trkpt.anchor = Some(zoom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the anchors of the trackpoints from the details of the path of the segment (see
|
||||||
|
/// [`compute_anchors`]), forgetting the previous ones.
|
||||||
|
pub fn compute_anchors(&mut self) {
|
||||||
|
let anchors = compute_anchors(self);
|
||||||
|
let mut anchors = anchors.into_iter().peekable();
|
||||||
|
self.update_all(|index, trkpt| {
|
||||||
|
trkpt.anchor = match anchors.peek() {
|
||||||
|
Some(&(anchor, zoom)) if anchor == index => {
|
||||||
|
anchors.next();
|
||||||
|
Some(zoom)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::Chunk;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn create_track_segment(nb_chunks: usize) -> TrackSegment {
|
||||||
|
let mut trkseg = TrackSegment::default();
|
||||||
|
let mut count = 0;
|
||||||
|
for n in 1..=nb_chunks {
|
||||||
|
let mut chunk = TrackpointChunk::default();
|
||||||
|
for _ in 0..n {
|
||||||
|
chunk.trkpt.push(Trackpoint {
|
||||||
|
ele: count as f64,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
trkseg.push(chunk);
|
||||||
|
}
|
||||||
|
trkseg
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eles(trkseg: &TrackSegment) -> Vec<f64> {
|
||||||
|
trkseg.iter().map(|p| p.ele).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn points(eles: &[f64]) -> Vec<Trackpoint> {
|
||||||
|
eles.iter()
|
||||||
|
.map(|&ele| Trackpoint {
|
||||||
|
ele,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_splice_replace_insert_delete_append() {
|
||||||
|
// chunks of 1..=5 points: [0] [1 2] [3 4 5] [6 7 8 9] [10..=14]
|
||||||
|
let mut trkseg = create_track_segment(5);
|
||||||
|
trkseg.splice(4, 8, points(&[-1.0, -2.0]));
|
||||||
|
let mut expected: Vec<f64> = (0..4).map(f64::from).collect();
|
||||||
|
expected.extend([-1.0, -2.0]);
|
||||||
|
expected.extend((8..15).map(f64::from));
|
||||||
|
assert_eq!(eles(&trkseg), expected);
|
||||||
|
assert_eq!(trkseg.len(), expected.len());
|
||||||
|
for i in 0..trkseg.len() {
|
||||||
|
assert_eq!(trkseg[i].ele, expected[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
trkseg.splice(2, 2, points(&[100.0]));
|
||||||
|
expected.insert(2, 100.0);
|
||||||
|
assert_eq!(eles(&trkseg), expected);
|
||||||
|
|
||||||
|
trkseg.splice(0, 3, vec![]);
|
||||||
|
expected.drain(..3);
|
||||||
|
assert_eq!(eles(&trkseg), expected);
|
||||||
|
|
||||||
|
let len = trkseg.len();
|
||||||
|
trkseg.splice(len, len, points(&[7.0, 8.0]));
|
||||||
|
expected.extend([7.0, 8.0]);
|
||||||
|
assert_eq!(eles(&trkseg), expected);
|
||||||
|
|
||||||
|
let len = trkseg.len();
|
||||||
|
trkseg.splice(0, len, vec![]);
|
||||||
|
assert_eq!(trkseg.len(), 0);
|
||||||
|
assert!(trkseg.first_index().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn anchors(trkseg: &TrackSegment) -> Vec<Option<u8>> {
|
||||||
|
trkseg.iter().map(|p| p.anchor).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_splice_keeps_the_ends_anchors() {
|
||||||
|
let mut trkseg = TrackSegment::default();
|
||||||
|
assert_eq!(anchors(&trkseg), vec![]);
|
||||||
|
trkseg.splice(0, 0, points(&[0.0, 1.0, 2.0]));
|
||||||
|
assert_eq!(anchors(&trkseg), [Some(0), None, Some(0)]);
|
||||||
|
|
||||||
|
// new ends: the previous ones stay anchors
|
||||||
|
trkseg.splice(3, 3, points(&[3.0]));
|
||||||
|
trkseg.splice(0, 0, points(&[-1.0]));
|
||||||
|
assert_eq!(anchors(&trkseg), [Some(0), Some(0), None, Some(0), Some(0)]);
|
||||||
|
|
||||||
|
// removing the ends: the new ones become anchors
|
||||||
|
trkseg.splice(4, 5, vec![]);
|
||||||
|
trkseg.splice(0, 2, vec![]);
|
||||||
|
assert_eq!(anchors(&trkseg), [Some(0), Some(0)]);
|
||||||
|
|
||||||
|
// a single point is both ends
|
||||||
|
trkseg.splice(1, 2, vec![]);
|
||||||
|
assert_eq!(anchors(&trkseg), [Some(0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_anchors_replaces_the_previous_ones() {
|
||||||
|
let mut trkseg = TrackSegment::default();
|
||||||
|
let line: Vec<_> = (0..10)
|
||||||
|
.map(|i| Trackpoint {
|
||||||
|
coordinates: crate::LngLat {
|
||||||
|
lng: i as f64 * 0.001,
|
||||||
|
lat: 0.0,
|
||||||
|
},
|
||||||
|
anchor: Some(5),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
trkseg.splice(0, 0, line);
|
||||||
|
trkseg.compute_anchors();
|
||||||
|
let mut expected = vec![None; 10];
|
||||||
|
expected[0] = Some(0);
|
||||||
|
expected[9] = Some(0);
|
||||||
|
assert_eq!(anchors(&trkseg), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_splice_keeps_untouched_chunks_shared() {
|
||||||
|
let mut trkseg = create_track_segment(5);
|
||||||
|
// the ends are anchors already, or their chunks would be copied
|
||||||
|
trkseg.ensure_end_anchors();
|
||||||
|
let before = trkseg.chunks().to_vec();
|
||||||
|
// inside the third chunk only
|
||||||
|
trkseg.splice(4, 5, points(&[-1.0]));
|
||||||
|
assert!(Rc::ptr_eq(&trkseg.chunks()[0], &before[0]));
|
||||||
|
assert!(Rc::ptr_eq(&trkseg.chunks()[1], &before[1]));
|
||||||
|
assert!(Rc::ptr_eq(trkseg.chunks().last().unwrap(), &before[4]));
|
||||||
|
assert!(Rc::ptr_eq(
|
||||||
|
&trkseg.chunks()[trkseg.chunks().len() - 2],
|
||||||
|
&before[3]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_splice_append_extends_last_chunk_and_respects_max_size() {
|
||||||
|
let mut trkseg = TrackSegment::default();
|
||||||
|
for i in 0..10_000 {
|
||||||
|
let len = trkseg.len();
|
||||||
|
trkseg.splice(len, len, points(&[i as f64]));
|
||||||
|
}
|
||||||
|
assert_eq!(trkseg.len(), 10_000);
|
||||||
|
assert!(trkseg.chunks().len() <= 3);
|
||||||
|
assert!(trkseg.chunks().iter().all(|c| c.trkpt.len() <= 4096));
|
||||||
|
assert_eq!(trkseg[9_999].ele, 9_999.0);
|
||||||
|
assert_eq!(trkseg.len(), 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_len() {
|
||||||
|
let nb_chunks = 10;
|
||||||
|
let trkseg = create_track_segment(nb_chunks);
|
||||||
|
assert_eq!(trkseg.len(), nb_chunks * (nb_chunks + 1) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_locate() {
|
||||||
|
let nb_chunks = 10;
|
||||||
|
let trkseg = create_track_segment(nb_chunks);
|
||||||
|
for i in 0..trkseg.len() {
|
||||||
|
let idx = trkseg.locate(i);
|
||||||
|
assert!(idx.is_some());
|
||||||
|
assert_eq!(trkseg[idx.unwrap()].ele, i as f64);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(trkseg.locate(trkseg.len()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_index() {
|
||||||
|
let nb_chunks = 10;
|
||||||
|
let trkseg = create_track_segment(nb_chunks);
|
||||||
|
for i in 0..trkseg.len() {
|
||||||
|
assert_eq!(trkseg[i].ele, i as f64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic]
|
||||||
|
fn test_index_out_of_bounds_1() {
|
||||||
|
let nb_chunks = 10;
|
||||||
|
let trkseg = create_track_segment(nb_chunks);
|
||||||
|
let _ = trkseg[trkseg.len()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic]
|
||||||
|
fn test_index_out_of_bounds_2() {
|
||||||
|
let nb_chunks = 10;
|
||||||
|
let trkseg = create_track_segment(nb_chunks);
|
||||||
|
let _ = trkseg[TrackSegmentIndex {
|
||||||
|
chunk: trkseg.chunks().len(),
|
||||||
|
pos: 0,
|
||||||
|
flat: 0,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_iter() {
|
||||||
|
let nb_chunks = 10;
|
||||||
|
let trkseg = create_track_segment(nb_chunks);
|
||||||
|
for (i, trkpt) in trkseg.iter().enumerate() {
|
||||||
|
assert!(std::ptr::eq(&trkseg[i], trkpt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_iter_nth() {
|
||||||
|
let nb_chunks = 10;
|
||||||
|
let trkseg = create_track_segment(nb_chunks);
|
||||||
|
let tenth = trkseg.iter().nth(10);
|
||||||
|
assert!(tenth.is_some());
|
||||||
|
assert!(std::ptr::eq(&trkseg[10], tenth.unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_iter_skip() {
|
||||||
|
let nb_chunks = 10;
|
||||||
|
let trkseg = create_track_segment(nb_chunks);
|
||||||
|
for (i, trkpt) in trkseg.iter().enumerate().skip(5) {
|
||||||
|
assert!(std::ptr::eq(&trkseg[i], trkpt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_first_last_index() {
|
||||||
|
assert!(TrackSegment::default().first_index().is_none());
|
||||||
|
assert!(TrackSegment::default().last_index().is_none());
|
||||||
|
|
||||||
|
let trkseg = create_track_segment(5); // 15 points in chunks of 1..=5
|
||||||
|
let first = trkseg.first_index().unwrap();
|
||||||
|
let last = trkseg.last_index().unwrap();
|
||||||
|
assert_eq!((first.chunk, first.pos, first.flat), (0, 0, 0));
|
||||||
|
assert_eq!((last.chunk, last.pos, last.flat), (4, 4, 14));
|
||||||
|
assert_eq!(trkseg[last].ele, 14.0);
|
||||||
|
assert_eq!(trkseg.locate(14), Some(last));
|
||||||
|
assert!(trkseg.next_index(Some(last)).is_none());
|
||||||
|
assert!(trkseg.prev_index(Some(first)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prev_next_are_inverse() {
|
||||||
|
let trkseg = create_track_segment(6);
|
||||||
|
let mut idx = trkseg.last_index();
|
||||||
|
let mut visited = vec![];
|
||||||
|
while let Some(i) = idx {
|
||||||
|
assert_eq!(trkseg.locate(i.flat), Some(i));
|
||||||
|
visited.push(i.flat);
|
||||||
|
idx = trkseg.prev_index(Some(i));
|
||||||
|
}
|
||||||
|
assert_eq!(visited, (0..trkseg.len()).rev().collect::<Vec<_>>());
|
||||||
|
|
||||||
|
let mut idx = trkseg.first_index();
|
||||||
|
while let Some(i) = idx {
|
||||||
|
let next = trkseg.next_index(Some(i));
|
||||||
|
if let Some(n) = next {
|
||||||
|
assert_eq!(trkseg.prev_index(Some(n)), Some(i));
|
||||||
|
}
|
||||||
|
idx = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn points_of(n: usize) -> Vec<Trackpoint> {
|
||||||
|
(0..n).map(|_| Trackpoint::default()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_every_change_gives_a_new_revision() {
|
||||||
|
type Change = (&'static str, fn(&mut TrackSegment));
|
||||||
|
let changes: [Change; 7] = [
|
||||||
|
("push", |s| s.push(TrackpointChunk::new(points_of(1)))),
|
||||||
|
("push_shared", |s| {
|
||||||
|
s.push_shared(Rc::new(TrackpointChunk::new(points_of(1))))
|
||||||
|
}),
|
||||||
|
("update", |s| s.update(0, |p| p.ele += 1.0)),
|
||||||
|
("update_all", |s| s.update_all(|_, p| p.ele += 1.0)),
|
||||||
|
("splice", |s| s.splice(1, 2, points_of(2))),
|
||||||
|
("set_anchor", |s| s.set_anchor(1, 5)),
|
||||||
|
("compute_anchors", |s| s.compute_anchors()),
|
||||||
|
];
|
||||||
|
for (name, change) in changes {
|
||||||
|
let mut trkseg = create_track_segment(3);
|
||||||
|
let before = trkseg.rev_id;
|
||||||
|
change(&mut trkseg);
|
||||||
|
assert_ne!(trkseg.rev_id, before, "{name}");
|
||||||
|
}
|
||||||
|
// reading does not
|
||||||
|
let trkseg = create_track_segment(3);
|
||||||
|
let before = trkseg.rev_id;
|
||||||
|
let _ = trkseg.iter().count();
|
||||||
|
assert_eq!(trkseg.rev_id, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ends_are_anchors_after_a_splice() {
|
||||||
|
let mut trkseg = create_track_segment(3);
|
||||||
|
trkseg.splice(0, 1, points_of(2));
|
||||||
|
let last = trkseg.len() - 1;
|
||||||
|
assert_eq!(trkseg[0].anchor, Some(0));
|
||||||
|
assert_eq!(trkseg[last].anchor, Some(0));
|
||||||
|
|
||||||
|
// nothing to anchor in an empty segment
|
||||||
|
let mut empty = TrackSegment::default();
|
||||||
|
empty.ensure_end_anchors();
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
use crate::{Link, TrackSegment};
|
||||||
|
|
||||||
|
use super::common::uuid_id;
|
||||||
|
|
||||||
|
uuid_id!(TrackId);
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct Track {
|
||||||
|
pub id: TrackId,
|
||||||
|
pub info: TrackInfo,
|
||||||
|
pub trkseg: Vec<TrackSegment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct TrackInfo {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub cmt: Option<String>,
|
||||||
|
pub desc: Option<String>,
|
||||||
|
pub src: Option<String>,
|
||||||
|
pub links: Vec<Link>,
|
||||||
|
pub type_: Option<String>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub opacity: Option<f64>,
|
||||||
|
pub width: Option<f64>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use crate::LngLat;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct Trackpoint {
|
||||||
|
pub coordinates: LngLat,
|
||||||
|
pub ele: f64,
|
||||||
|
pub time: Option<i64>,
|
||||||
|
pub atemp: Option<i16>,
|
||||||
|
pub hr: Option<u16>,
|
||||||
|
pub cad: Option<u16>,
|
||||||
|
pub power: Option<u16>,
|
||||||
|
/// Code of the surface, see [`crate::TrackpointCategories`].
|
||||||
|
pub surface: Option<u8>,
|
||||||
|
/// Code of the highway, see [`crate::TrackpointCategories`].
|
||||||
|
pub highway: Option<u8>,
|
||||||
|
/// Code of the SAC hiking scale, see [`crate::TrackpointCategories`].
|
||||||
|
pub sac_scale: Option<u8>,
|
||||||
|
/// Code of the mountain biking scale, see [`crate::TrackpointCategories`].
|
||||||
|
pub mtb_scale: Option<u8>,
|
||||||
|
/// Whether the trackpoint is an anchor of the routing tool (a point the user can drag to
|
||||||
|
/// reroute the segment): the lowest map zoom level at which it is shown. The first and last
|
||||||
|
/// trackpoints of a segment are always anchors that are always shown (zoom 0).
|
||||||
|
pub anchor: Option<u8>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
use crate::{Link, LngLat};
|
||||||
|
|
||||||
|
use super::common::uuid_id;
|
||||||
|
|
||||||
|
uuid_id!(WaypointId);
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct Waypoint {
|
||||||
|
pub id: WaypointId,
|
||||||
|
pub coordinates: LngLat,
|
||||||
|
pub ele: f64,
|
||||||
|
pub time: Option<i64>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub cmt: Option<String>,
|
||||||
|
pub desc: Option<String>,
|
||||||
|
pub links: Vec<Link>,
|
||||||
|
pub sym: Option<String>,
|
||||||
|
pub type_: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Waypoint {
|
||||||
|
/// Whether the waypoints are the same, whatever their ids.
|
||||||
|
pub fn same_content(&self, other: &Waypoint) -> bool {
|
||||||
|
self.coordinates.lng == other.coordinates.lng
|
||||||
|
&& self.coordinates.lat == other.coordinates.lat
|
||||||
|
&& self.ele == other.ele
|
||||||
|
&& self.time == other.time
|
||||||
|
&& self.name == other.name
|
||||||
|
&& self.cmt == other.cmt
|
||||||
|
&& self.desc == other.desc
|
||||||
|
&& self.links == other.links
|
||||||
|
&& self.sym == other.sym
|
||||||
|
&& self.type_ == other.type_
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
use std::ops::Deref;
|
||||||
|
|
||||||
|
use crate::{Chunked, Waypoint, WaypointChunk, WaypointId};
|
||||||
|
|
||||||
|
use super::common::uuid_id;
|
||||||
|
|
||||||
|
uuid_id!(FileWaypointsRevisionId);
|
||||||
|
|
||||||
|
/// The waypoints of a file, in chunks that are shared between the versions of the file (see
|
||||||
|
/// [`Chunked`]).
|
||||||
|
///
|
||||||
|
/// Everything that changes the waypoints changes the revision too, which is why there is no
|
||||||
|
/// mutable access to the list itself: the revision tells what is derived from the waypoints
|
||||||
|
/// (coordinates for the map...) that it is out of date.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct Waypoints {
|
||||||
|
chunks: Chunked<WaypointChunk>,
|
||||||
|
/// Changes when the waypoints do.
|
||||||
|
pub rev_id: FileWaypointsRevisionId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for Waypoints {
|
||||||
|
type Target = Chunked<WaypointChunk>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.chunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Waypoints {
|
||||||
|
/// Waypoints made of the given chunks, which are used as they are.
|
||||||
|
pub fn new(chunks: impl IntoIterator<Item = WaypointChunk>) -> Self {
|
||||||
|
let mut waypoints = Self::default();
|
||||||
|
for chunk in chunks {
|
||||||
|
waypoints.push(chunk);
|
||||||
|
}
|
||||||
|
waypoints
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a chunk after the last waypoint.
|
||||||
|
pub fn push(&mut self, chunk: WaypointChunk) {
|
||||||
|
self.chunks.push(chunk);
|
||||||
|
self.rev_id = Default::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a chunk that is shared with something else after the last waypoint.
|
||||||
|
pub fn push_shared(&mut self, chunk: std::rc::Rc<WaypointChunk>) {
|
||||||
|
self.chunks.push_shared(chunk);
|
||||||
|
self.rev_id = Default::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the waypoints in `start..end` by `waypoints`, see [`Chunked::splice`].
|
||||||
|
pub fn splice(&mut self, start: usize, end: usize, waypoints: Vec<Waypoint>) {
|
||||||
|
self.chunks.splice(start, end, waypoints);
|
||||||
|
self.rev_id = Default::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts waypoints, so that the first one is at `index` among the waypoints (at the end if
|
||||||
|
/// the index is past it). Only the chunks around the insertion are copied.
|
||||||
|
pub fn insert_at(&mut self, index: usize, waypoints: Vec<Waypoint>) {
|
||||||
|
if waypoints.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let index = index.min(self.len());
|
||||||
|
self.splice(index, index, waypoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts waypoints right after the waypoint `after`, or at the end if there is none (or if
|
||||||
|
/// it is not there). See [`Waypoints::insert_at`].
|
||||||
|
pub fn insert_after(&mut self, after: Option<WaypointId>, waypoints: Vec<Waypoint>) {
|
||||||
|
let index = after
|
||||||
|
.and_then(|after| self.position(after))
|
||||||
|
.map_or(usize::MAX, |i| i + 1);
|
||||||
|
self.insert_at(index, waypoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The position of the waypoint among the waypoints.
|
||||||
|
pub fn position(&self, id: WaypointId) -> Option<usize> {
|
||||||
|
self.iter().position(|wpt| wpt.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edits the waypoints of the chunks that contain one accepted by `filter`, see
|
||||||
|
/// [`Chunked::edit`]. Returns whether anything changed.
|
||||||
|
pub fn edit(
|
||||||
|
&mut self,
|
||||||
|
filter: impl Fn(&Waypoint) -> bool,
|
||||||
|
f: impl FnMut(&mut Vec<Waypoint>) -> bool,
|
||||||
|
) -> bool {
|
||||||
|
let changed = self.chunks.edit(filter, f);
|
||||||
|
if changed {
|
||||||
|
self.rev_id = Default::default();
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn waypoint() -> Waypoint {
|
||||||
|
Waypoint::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn waypoints_with(chunks: &[usize]) -> (Waypoints, Vec<WaypointId>) {
|
||||||
|
let mut waypoints = Waypoints::default();
|
||||||
|
let mut ids = vec![];
|
||||||
|
for n in chunks {
|
||||||
|
let wpt: Vec<_> = (0..*n).map(|_| waypoint()).collect();
|
||||||
|
ids.extend(wpt.iter().map(|w| w.id));
|
||||||
|
waypoints.push(WaypointChunk {
|
||||||
|
wpt,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(waypoints, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ids(waypoints: &Waypoints) -> Vec<WaypointId> {
|
||||||
|
waypoints.iter().map(|w| w.id).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_and_push() {
|
||||||
|
let (waypoints, ids_) = waypoints_with(&[2, 1]);
|
||||||
|
assert_eq!(waypoints.len(), 3);
|
||||||
|
assert_eq!(waypoints.chunks().len(), 2);
|
||||||
|
assert_eq!(ids(&waypoints), ids_);
|
||||||
|
assert_eq!(waypoints.position(ids_[2]), Some(2));
|
||||||
|
assert_eq!(waypoints.position(WaypointId::default()), None);
|
||||||
|
// empty chunks are dropped
|
||||||
|
let waypoints = Waypoints::new([WaypointChunk::default()]);
|
||||||
|
assert!(waypoints.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insert_at_the_end() {
|
||||||
|
let (mut waypoints, before) = waypoints_with(&[2, 1]);
|
||||||
|
let first = waypoints.chunks()[0].clone();
|
||||||
|
let rev = waypoints.rev_id;
|
||||||
|
let new = vec![waypoint(), waypoint()];
|
||||||
|
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
|
||||||
|
waypoints.insert_after(None, new);
|
||||||
|
assert_eq!(ids(&waypoints), [before.clone(), new_ids].concat());
|
||||||
|
// the chunk that is not touched is kept, the last one is extended
|
||||||
|
assert_eq!(waypoints.chunks().len(), 2);
|
||||||
|
assert!(Rc::ptr_eq(&waypoints.chunks()[0], &first));
|
||||||
|
assert_ne!(waypoints.rev_id, rev);
|
||||||
|
// an unknown waypoint is an insertion at the end too
|
||||||
|
let (mut waypoints, before) = waypoints_with(&[2]);
|
||||||
|
let one = waypoint();
|
||||||
|
let one_id = one.id;
|
||||||
|
waypoints.insert_after(Some(WaypointId::default()), vec![one]);
|
||||||
|
assert_eq!(ids(&waypoints), [before, vec![one_id]].concat());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insert_after_the_last_waypoint_of_a_chunk_keeps_the_next_ones() {
|
||||||
|
let (mut waypoints, before) = waypoints_with(&[2, 2]);
|
||||||
|
let last = waypoints.chunks()[1].clone();
|
||||||
|
let one = waypoint();
|
||||||
|
let one_id = one.id;
|
||||||
|
waypoints.insert_after(Some(before[1]), vec![one]);
|
||||||
|
assert_eq!(
|
||||||
|
ids(&waypoints),
|
||||||
|
[&before[..2], &[one_id], &before[2..]].concat()
|
||||||
|
);
|
||||||
|
assert_eq!(waypoints.chunks().len(), 2);
|
||||||
|
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insert_in_the_middle_of_a_chunk_cuts_it() {
|
||||||
|
let (mut waypoints, before) = waypoints_with(&[3, 1]);
|
||||||
|
let last = waypoints.chunks()[1].clone();
|
||||||
|
let new = vec![waypoint(), waypoint()];
|
||||||
|
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
|
||||||
|
waypoints.insert_after(Some(before[0]), new);
|
||||||
|
assert_eq!(
|
||||||
|
ids(&waypoints),
|
||||||
|
[&before[..1], &new_ids[..], &before[1..]].concat()
|
||||||
|
);
|
||||||
|
// the cut chunk is refilled, the other one is kept
|
||||||
|
assert_eq!(waypoints.chunks().len(), 2);
|
||||||
|
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insert_nothing() {
|
||||||
|
let (mut waypoints, before) = waypoints_with(&[2]);
|
||||||
|
let rev = waypoints.rev_id;
|
||||||
|
waypoints.insert_after(Some(before[0]), vec![]);
|
||||||
|
assert_eq!(ids(&waypoints), before);
|
||||||
|
assert_eq!(waypoints.rev_id, rev);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insert_many_fills_chunks() {
|
||||||
|
let mut waypoints = Waypoints::default();
|
||||||
|
let new: Vec<_> = (0..300).map(|_| waypoint()).collect();
|
||||||
|
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
|
||||||
|
waypoints.insert_after(None, new);
|
||||||
|
assert_eq!(ids(&waypoints), new_ids);
|
||||||
|
assert_eq!(waypoints.chunks().len(), 3);
|
||||||
|
assert!(waypoints.chunks()[..2].iter().all(|chunk| {
|
||||||
|
use crate::Chunk;
|
||||||
|
chunk.is_full()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insert_at_an_index() {
|
||||||
|
// at the start
|
||||||
|
let (mut waypoints, before) = waypoints_with(&[2, 1]);
|
||||||
|
let first = waypoints.chunks()[0].clone();
|
||||||
|
let one = waypoint();
|
||||||
|
let one_id = one.id;
|
||||||
|
waypoints.insert_at(0, vec![one]);
|
||||||
|
assert_eq!(ids(&waypoints), [vec![one_id], before.clone()].concat());
|
||||||
|
// the existing chunks are kept
|
||||||
|
assert_eq!(waypoints.chunks().len(), 3);
|
||||||
|
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &first));
|
||||||
|
|
||||||
|
// between two chunks
|
||||||
|
let (mut waypoints, before) = waypoints_with(&[2, 1]);
|
||||||
|
let last = waypoints.chunks()[1].clone();
|
||||||
|
let one = waypoint();
|
||||||
|
let one_id = one.id;
|
||||||
|
waypoints.insert_at(2, vec![one]);
|
||||||
|
assert_eq!(
|
||||||
|
ids(&waypoints),
|
||||||
|
[&before[..2], &[one_id], &before[2..]].concat()
|
||||||
|
);
|
||||||
|
assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last));
|
||||||
|
|
||||||
|
// inside a chunk
|
||||||
|
let (mut waypoints, before) = waypoints_with(&[3]);
|
||||||
|
let one = waypoint();
|
||||||
|
let one_id = one.id;
|
||||||
|
waypoints.insert_at(1, vec![one]);
|
||||||
|
assert_eq!(
|
||||||
|
ids(&waypoints),
|
||||||
|
[&before[..1], &[one_id], &before[1..]].concat()
|
||||||
|
);
|
||||||
|
|
||||||
|
// at the end, or past it
|
||||||
|
for index in [3, 100, usize::MAX] {
|
||||||
|
let (mut waypoints, before) = waypoints_with(&[3]);
|
||||||
|
let one = waypoint();
|
||||||
|
let one_id = one.id;
|
||||||
|
waypoints.insert_at(index, vec![one]);
|
||||||
|
assert_eq!(ids(&waypoints), [before, vec![one_id]].concat());
|
||||||
|
}
|
||||||
|
|
||||||
|
// in a file without waypoints
|
||||||
|
let mut waypoints = Waypoints::default();
|
||||||
|
let one = waypoint();
|
||||||
|
let one_id = one.id;
|
||||||
|
waypoints.insert_at(0, vec![one]);
|
||||||
|
assert_eq!(ids(&waypoints), vec![one_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_splice_replaces_waypoints() {
|
||||||
|
let (mut waypoints, before) = waypoints_with(&[2, 2]);
|
||||||
|
let rev = waypoints.rev_id;
|
||||||
|
waypoints.splice(1, 3, vec![]);
|
||||||
|
assert_eq!(ids(&waypoints), [before[0], before[3]]);
|
||||||
|
assert_ne!(waypoints.rev_id, rev);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_changes_the_chunks_with_a_match_only() {
|
||||||
|
let (mut waypoints, before) = waypoints_with(&[2, 2, 1]);
|
||||||
|
let (first, last) = (waypoints.chunks()[0].clone(), waypoints.chunks()[2].clone());
|
||||||
|
let rev = waypoints.rev_id;
|
||||||
|
let target = before[2];
|
||||||
|
let changed = waypoints.edit(
|
||||||
|
|wpt| wpt.id == target,
|
||||||
|
|wpts| {
|
||||||
|
wpts.retain(|wpt| wpt.id != target);
|
||||||
|
true
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(changed);
|
||||||
|
assert_ne!(waypoints.rev_id, rev);
|
||||||
|
assert_eq!(ids(&waypoints), [&before[..2], &before[3..]].concat());
|
||||||
|
// the chunks without a match are the same ones, the changed one is new
|
||||||
|
assert!(Rc::ptr_eq(&waypoints.chunks()[0], &first));
|
||||||
|
assert!(Rc::ptr_eq(&waypoints.chunks()[2], &last));
|
||||||
|
assert_eq!(waypoints.chunks().len(), 3);
|
||||||
|
|
||||||
|
// a chunk left empty is dropped
|
||||||
|
let (mut waypoints, before) = waypoints_with(&[1, 2]);
|
||||||
|
waypoints.edit(
|
||||||
|
|wpt| wpt.id == before[0],
|
||||||
|
|wpts| {
|
||||||
|
wpts.clear();
|
||||||
|
true
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(waypoints.chunks().len(), 1);
|
||||||
|
assert_eq!(ids(&waypoints), before[1..]);
|
||||||
|
|
||||||
|
// a chunk that is not changed is kept, and so is the revision
|
||||||
|
let (mut waypoints, before) = waypoints_with(&[2]);
|
||||||
|
let (chunk, rev) = (waypoints.chunks()[0].clone(), waypoints.rev_id);
|
||||||
|
assert!(!waypoints.edit(|wpt| wpt.id == before[0], |_| false));
|
||||||
|
assert!(Rc::ptr_eq(&waypoints.chunks()[0], &chunk));
|
||||||
|
assert_eq!(waypoints.rev_id, rev);
|
||||||
|
assert!(!waypoints.edit(|_| false, |_| true));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mod parse;
|
||||||
|
mod write;
|
||||||
|
|
||||||
|
pub use parse::*;
|
||||||
|
pub use write::*;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,766 @@
|
|||||||
|
use std::io;
|
||||||
|
|
||||||
|
use chrono::DateTime;
|
||||||
|
use quick_xml::Writer;
|
||||||
|
use quick_xml::events::{BytesDecl, BytesText, Event};
|
||||||
|
|
||||||
|
use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
|
||||||
|
|
||||||
|
/// What the written file contains: the data of the trackpoints that are `false` are left out.
|
||||||
|
/// The same type tells which data a file has, see [`File::exportable_data`].
|
||||||
|
///
|
||||||
|
/// With `as_route`, the file is written with routes instead of tracks, see [`write`]. It is not
|
||||||
|
/// data: it is off in `ALL` and `NONE`, and in what [`File::exportable_data`] gives.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ExportOptions {
|
||||||
|
pub time: bool,
|
||||||
|
pub hr: bool,
|
||||||
|
pub cad: bool,
|
||||||
|
pub atemp: bool,
|
||||||
|
pub power: bool,
|
||||||
|
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
|
||||||
|
pub osm: bool,
|
||||||
|
/// Writes every segment as a route (`rte`) instead of a track.
|
||||||
|
pub as_route: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExportOptions {
|
||||||
|
pub const ALL: Self = Self::of(true);
|
||||||
|
pub const NONE: Self = Self::of(false);
|
||||||
|
|
||||||
|
const fn of(value: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
time: value,
|
||||||
|
hr: value,
|
||||||
|
cad: value,
|
||||||
|
atemp: value,
|
||||||
|
power: value,
|
||||||
|
osm: value,
|
||||||
|
as_route: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The options that are on in `self` or in `other`.
|
||||||
|
pub fn union(self, other: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
time: self.time || other.time,
|
||||||
|
hr: self.hr || other.hr,
|
||||||
|
cad: self.cad || other.cad,
|
||||||
|
atemp: self.atemp || other.atemp,
|
||||||
|
power: self.power || other.power,
|
||||||
|
osm: self.osm || other.osm,
|
||||||
|
as_route: self.as_route || other.as_route,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl File {
|
||||||
|
/// The data that the trackpoints of the file have (and waypoints for the time), which the
|
||||||
|
/// options of [`write`] can leave out.
|
||||||
|
pub fn exportable_data(&self) -> ExportOptions {
|
||||||
|
let mut data = ExportOptions::NONE;
|
||||||
|
for point in self
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.flat_map(|trk| &trk.trkseg)
|
||||||
|
.flat_map(|seg| seg.iter())
|
||||||
|
{
|
||||||
|
data.time |= point.time.is_some();
|
||||||
|
data.hr |= point.hr.is_some();
|
||||||
|
data.cad |= point.cad.is_some();
|
||||||
|
data.atemp |= point.atemp.is_some();
|
||||||
|
data.power |= point.power.is_some();
|
||||||
|
data.osm |= point.surface.is_some()
|
||||||
|
|| point.highway.is_some()
|
||||||
|
|| point.sac_scale.is_some()
|
||||||
|
|| point.mtb_scale.is_some();
|
||||||
|
if data == ExportOptions::ALL {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEMA_LOCATION: &str = "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";
|
||||||
|
|
||||||
|
type XmlWriter = Writer<Vec<u8>>;
|
||||||
|
|
||||||
|
/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are
|
||||||
|
/// the names of their codes in `categories`.
|
||||||
|
///
|
||||||
|
/// The segments are written as tracks, or with `options.as_route` as routes: a segment becomes a
|
||||||
|
/// `rte` that has the information of its track, its anchors are the `rtept` and the trackpoints
|
||||||
|
/// between two anchors are the `gpxx:rpt` of the `rtept` before them (which only have a
|
||||||
|
/// position, unlike the `rtept`). The first and last trackpoints always are `rtept`.
|
||||||
|
pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> {
|
||||||
|
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
|
||||||
|
// writing to memory does not fail
|
||||||
|
write_file(&mut writer, file, categories, options).expect("writing to memory");
|
||||||
|
writer.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_file(
|
||||||
|
w: &mut XmlWriter,
|
||||||
|
file: &File,
|
||||||
|
categories: &TrackpointCategories,
|
||||||
|
options: ExportOptions,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
|
||||||
|
w.create_element("gpx")
|
||||||
|
.with_attributes([
|
||||||
|
("creator", "https://gpx.studio"),
|
||||||
|
("version", "1.1"),
|
||||||
|
("xmlns", "http://www.topografix.com/GPX/1/1"),
|
||||||
|
("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"),
|
||||||
|
("xsi:schemaLocation", SCHEMA_LOCATION),
|
||||||
|
(
|
||||||
|
"xmlns:gpxtpx",
|
||||||
|
"http://www.garmin.com/xmlschemas/TrackPointExtension/v1",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"xmlns:gpxx",
|
||||||
|
"http://www.garmin.com/xmlschemas/GpxExtensions/v3",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"xmlns:gpxpx",
|
||||||
|
"http://www.garmin.com/xmlschemas/PowerExtension/v1",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"xmlns:gpx_style",
|
||||||
|
"http://www.topografix.com/GPX/gpx_style/0/2",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.write_inner_content(|w| {
|
||||||
|
write_metadata(w, file, options)?;
|
||||||
|
for wpt in file.wpt.iter() {
|
||||||
|
write_waypoint(w, wpt, options)?;
|
||||||
|
}
|
||||||
|
for trk in &file.trk {
|
||||||
|
// a single unnamed track is named like the file
|
||||||
|
let name = match (&trk.info.name, file.trk.len()) {
|
||||||
|
(Some(name), _) if !name.is_empty() => Some(name.as_str()),
|
||||||
|
(_, 1) => Some(file.info.name.as_str()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if options.as_route {
|
||||||
|
for segment in trk.trkseg.iter().filter(|segment| !segment.is_empty()) {
|
||||||
|
write_route(w, trk, segment, name, categories, options)?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
write_track(w, trk, name, categories, options)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(w: &mut XmlWriter, name: &str, value: &str) -> io::Result<()> {
|
||||||
|
if !value.is_empty() {
|
||||||
|
w.create_element(name)
|
||||||
|
.write_text_content(BytesText::new(value))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_text(w: &mut XmlWriter, name: &str, value: &Option<String>) -> io::Result<()> {
|
||||||
|
text(w, name, value.as_deref().unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number(w: &mut XmlWriter, name: &str, value: impl std::fmt::Display) -> io::Result<()> {
|
||||||
|
text(w, name, &value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn time(w: &mut XmlWriter, millis: Option<i64>) -> io::Result<()> {
|
||||||
|
if let Some(time) = millis.and_then(DateTime::from_timestamp_millis) {
|
||||||
|
text(
|
||||||
|
w,
|
||||||
|
"time",
|
||||||
|
&time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link(w: &mut XmlWriter, link: &Link) -> io::Result<()> {
|
||||||
|
if !link.href.is_empty() {
|
||||||
|
w.create_element("link")
|
||||||
|
.with_attribute(("href", link.href.as_str()))
|
||||||
|
.write_inner_content(|w| optional_text(w, "text", &link.text))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn links(w: &mut XmlWriter, links: &[Link]) -> io::Result<()> {
|
||||||
|
links.iter().try_for_each(|l| link(w, l))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_metadata(w: &mut XmlWriter, file: &File, options: ExportOptions) -> io::Result<()> {
|
||||||
|
let info = &file.info;
|
||||||
|
let time = options.time.then_some(info.time).flatten();
|
||||||
|
if info.name.is_empty()
|
||||||
|
&& info.desc.is_none()
|
||||||
|
&& info.author.is_none()
|
||||||
|
&& info.links.is_empty()
|
||||||
|
&& time.is_none()
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
w.create_element("metadata").write_inner_content(|w| {
|
||||||
|
text(w, "name", &info.name)?;
|
||||||
|
optional_text(w, "desc", &info.desc)?;
|
||||||
|
if let Some(author) = &info.author {
|
||||||
|
w.create_element("author").write_inner_content(|w| {
|
||||||
|
optional_text(w, "name", &author.name)?;
|
||||||
|
if let Some((id, domain)) = author.email.as_deref().and_then(|e| e.split_once('@'))
|
||||||
|
{
|
||||||
|
w.create_element("email")
|
||||||
|
.with_attributes([("id", id), ("domain", domain)])
|
||||||
|
.write_empty()?;
|
||||||
|
}
|
||||||
|
author.link.iter().try_for_each(|l| link(w, l))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
links(w, &info.links)?;
|
||||||
|
self::time(w, time)
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_coordinates<'a>(
|
||||||
|
w: &'a mut XmlWriter,
|
||||||
|
name: &'a str,
|
||||||
|
coordinates: crate::LngLat,
|
||||||
|
) -> quick_xml::writer::ElementWriter<'a, Vec<u8>> {
|
||||||
|
w.create_element(name).with_attributes([
|
||||||
|
("lat", coordinates.lat.to_string().as_str()),
|
||||||
|
("lon", coordinates.lng.to_string().as_str()),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_waypoint(w: &mut XmlWriter, wpt: &Waypoint, options: ExportOptions) -> io::Result<()> {
|
||||||
|
write_coordinates(w, "wpt", wpt.coordinates).write_inner_content(|w| {
|
||||||
|
number(w, "ele", wpt.ele)?;
|
||||||
|
if options.time {
|
||||||
|
time(w, wpt.time)?;
|
||||||
|
}
|
||||||
|
optional_text(w, "name", &wpt.name)?;
|
||||||
|
optional_text(w, "cmt", &wpt.cmt)?;
|
||||||
|
optional_text(w, "desc", &wpt.desc)?;
|
||||||
|
links(w, &wpt.links)?;
|
||||||
|
optional_text(w, "sym", &wpt.sym)?;
|
||||||
|
optional_text(w, "type", &wpt.type_)
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The information of a track that its routes have too: name, comment, description, source,
|
||||||
|
/// links, type and style.
|
||||||
|
fn write_track_info(
|
||||||
|
w: &mut XmlWriter,
|
||||||
|
name: Option<&str>,
|
||||||
|
info: &crate::TrackInfo,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
text(w, "name", name.unwrap_or_default())?;
|
||||||
|
optional_text(w, "cmt", &info.cmt)?;
|
||||||
|
optional_text(w, "desc", &info.desc)?;
|
||||||
|
optional_text(w, "src", &info.src)?;
|
||||||
|
links(w, &info.links)?;
|
||||||
|
optional_text(w, "type", &info.type_)?;
|
||||||
|
if info.color.is_some() || info.opacity.is_some() || info.width.is_some() {
|
||||||
|
w.create_element("extensions").write_inner_content(|w| {
|
||||||
|
w.create_element("gpx_style:line")
|
||||||
|
.write_inner_content(|w| {
|
||||||
|
optional_text(w, "gpx_style:color", &info.color)?;
|
||||||
|
if let Some(opacity) = info.opacity {
|
||||||
|
number(w, "gpx_style:opacity", opacity)?;
|
||||||
|
}
|
||||||
|
if let Some(width) = info.width {
|
||||||
|
number(w, "gpx_style:width", width)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_track(
|
||||||
|
w: &mut XmlWriter,
|
||||||
|
trk: &Track,
|
||||||
|
name: Option<&str>,
|
||||||
|
categories: &TrackpointCategories,
|
||||||
|
options: ExportOptions,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
w.create_element("trk").write_inner_content(|w| {
|
||||||
|
write_track_info(w, name, &trk.info)?;
|
||||||
|
for segment in &trk.trkseg {
|
||||||
|
w.create_element("trkseg").write_inner_content(|w| {
|
||||||
|
for point in segment.iter() {
|
||||||
|
write_point(w, "trkpt", point, &[], categories, options)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_route(
|
||||||
|
w: &mut XmlWriter,
|
||||||
|
trk: &Track,
|
||||||
|
segment: &crate::TrackSegment,
|
||||||
|
name: Option<&str>,
|
||||||
|
categories: &TrackpointCategories,
|
||||||
|
options: ExportOptions,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
w.create_element("rte").write_inner_content(|w| {
|
||||||
|
write_track_info(w, name, &trk.info)?;
|
||||||
|
let points: Vec<&Trackpoint> = segment.iter().collect();
|
||||||
|
let last = points.len() - 1;
|
||||||
|
let is_anchor = |i: usize| i == 0 || i == last || points[i].anchor.is_some();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < points.len() {
|
||||||
|
// the trackpoints up to the next anchor
|
||||||
|
let mut next = i + 1;
|
||||||
|
while next < points.len() && !is_anchor(next) {
|
||||||
|
next += 1;
|
||||||
|
}
|
||||||
|
write_point(
|
||||||
|
w,
|
||||||
|
"rtept",
|
||||||
|
points[i],
|
||||||
|
&points[i + 1..next],
|
||||||
|
categories,
|
||||||
|
options,
|
||||||
|
)?;
|
||||||
|
i = next;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trackpoint or a route point (`tag`). A route point has the `path` that leads to the next one,
|
||||||
|
/// of which only the positions are written.
|
||||||
|
fn write_point(
|
||||||
|
w: &mut XmlWriter,
|
||||||
|
tag: &str,
|
||||||
|
point: &Trackpoint,
|
||||||
|
path: &[&Trackpoint],
|
||||||
|
categories: &TrackpointCategories,
|
||||||
|
options: ExportOptions,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let atemp = point.atemp.filter(|_| options.atemp);
|
||||||
|
let hr = point.hr.filter(|_| options.hr);
|
||||||
|
let cad = point.cad.filter(|_| options.cad);
|
||||||
|
let power = point.power.filter(|_| options.power);
|
||||||
|
let osm = [
|
||||||
|
("surface", point.surface, &categories.surface),
|
||||||
|
("highway", point.highway, &categories.highway),
|
||||||
|
("sac_scale", point.sac_scale, &categories.sac_scale),
|
||||||
|
("mtb_scale", point.mtb_scale, &categories.mtb_scale),
|
||||||
|
]
|
||||||
|
.map(|(name, code, categories)| {
|
||||||
|
let value = code
|
||||||
|
.filter(|_| options.osm)
|
||||||
|
.and_then(|code| categories.name(code));
|
||||||
|
(name, value)
|
||||||
|
});
|
||||||
|
let has_osm = osm.iter().any(|(_, value)| value.is_some());
|
||||||
|
let has_track_point_extension = atemp.is_some() || hr.is_some() || cad.is_some() || has_osm;
|
||||||
|
|
||||||
|
write_coordinates(w, tag, point.coordinates).write_inner_content(|w| {
|
||||||
|
number(w, "ele", point.ele)?;
|
||||||
|
if options.time {
|
||||||
|
time(w, point.time)?;
|
||||||
|
}
|
||||||
|
if !has_track_point_extension && power.is_none() && path.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
w.create_element("extensions").write_inner_content(|w| {
|
||||||
|
if has_track_point_extension {
|
||||||
|
w.create_element("gpxtpx:TrackPointExtension")
|
||||||
|
.write_inner_content(|w| {
|
||||||
|
if let Some(atemp) = atemp {
|
||||||
|
number(w, "gpxtpx:atemp", atemp)?;
|
||||||
|
}
|
||||||
|
if let Some(hr) = hr {
|
||||||
|
number(w, "gpxtpx:hr", hr)?;
|
||||||
|
}
|
||||||
|
if let Some(cad) = cad {
|
||||||
|
number(w, "gpxtpx:cad", cad)?;
|
||||||
|
}
|
||||||
|
if has_osm {
|
||||||
|
w.create_element("gpxtpx:Extensions")
|
||||||
|
.write_inner_content(|w| {
|
||||||
|
for (name, value) in osm {
|
||||||
|
text(w, name, value.unwrap_or_default())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
if let Some(power) = power {
|
||||||
|
w.create_element("gpxpx:PowerExtension")
|
||||||
|
.write_inner_content(|w| number(w, "gpxpx:PowerInWatts", power))?;
|
||||||
|
}
|
||||||
|
if !path.is_empty() {
|
||||||
|
w.create_element("gpxx:RoutePointExtension")
|
||||||
|
.write_inner_content(|w| {
|
||||||
|
for point in path {
|
||||||
|
write_coordinates(w, "gpxx:rpt", point.coordinates).write_empty()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::parse;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn read(name: &str) -> (File, TrackpointCategories) {
|
||||||
|
let mut categories = TrackpointCategories::default();
|
||||||
|
let data = std::fs::read(format!("data/{name}.gpx")).unwrap();
|
||||||
|
(parse(&data, &mut categories).unwrap(), categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn written_text(
|
||||||
|
file: &File,
|
||||||
|
categories: &TrackpointCategories,
|
||||||
|
options: ExportOptions,
|
||||||
|
) -> String {
|
||||||
|
String::from_utf8(write(file, categories, options)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a file and reads it again.
|
||||||
|
fn round_trip(name: &str, options: ExportOptions) -> (File, File, TrackpointCategories) {
|
||||||
|
let (file, mut categories) = read(name);
|
||||||
|
let written = write(&file, &categories, options);
|
||||||
|
let again = parse(&written, &mut categories).unwrap();
|
||||||
|
(file, again, categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn points(file: &File) -> Vec<Trackpoint> {
|
||||||
|
file.trk
|
||||||
|
.iter()
|
||||||
|
.flat_map(|trk| &trk.trkseg)
|
||||||
|
.flat_map(|seg| seg.iter().cloned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_points(a: &[Trackpoint], b: &[Trackpoint]) {
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
for (a, b) in a.iter().zip(b) {
|
||||||
|
assert_eq!(a.coordinates.lat, b.coordinates.lat);
|
||||||
|
assert_eq!(a.coordinates.lng, b.coordinates.lng);
|
||||||
|
assert_eq!(a.ele, b.ele);
|
||||||
|
assert_eq!(a.time, b.time);
|
||||||
|
assert_eq!(a.atemp, b.atemp);
|
||||||
|
assert_eq!(a.hr, b.hr);
|
||||||
|
assert_eq!(a.cad, b.cad);
|
||||||
|
assert_eq!(a.power, b.power);
|
||||||
|
assert_eq!(a.surface, b.surface);
|
||||||
|
assert_eq!(a.highway, b.highway);
|
||||||
|
assert_eq!(a.sac_scale, b.sac_scale);
|
||||||
|
assert_eq!(a.mtb_scale, b.mtb_scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_links_and_the_email_are_written() {
|
||||||
|
let data = std::fs::read("data/with_links.gpx").unwrap();
|
||||||
|
let file = parse(&data, &mut Default::default()).unwrap();
|
||||||
|
let again = parse(
|
||||||
|
&write(&file, &Default::default(), ExportOptions::ALL),
|
||||||
|
&mut Default::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(again.info.links, file.info.links);
|
||||||
|
assert_eq!(again.info.links.len(), 2);
|
||||||
|
assert_eq!(again.info.author, file.info.author);
|
||||||
|
assert_eq!(
|
||||||
|
again.info.author.as_ref().unwrap().email.as_deref(),
|
||||||
|
Some("someone@example.com")
|
||||||
|
);
|
||||||
|
let waypoints = |file: &File| file.wpt.iter().map(|w| w.links.clone()).collect::<Vec<_>>();
|
||||||
|
assert_eq!(waypoints(&again), waypoints(&file));
|
||||||
|
assert_eq!(again.wpt.iter().next().unwrap().links.len(), 3);
|
||||||
|
for (a, b) in again.trk.iter().zip(&file.trk) {
|
||||||
|
assert_eq!(a.info.links, b.info.links);
|
||||||
|
assert_eq!(a.info.links.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_files_survive_a_round_trip() {
|
||||||
|
for name in [
|
||||||
|
"simple",
|
||||||
|
"with_links",
|
||||||
|
"with_time",
|
||||||
|
"with_hr",
|
||||||
|
"with_cad",
|
||||||
|
"with_temp",
|
||||||
|
"with_power_1",
|
||||||
|
"with_power_2",
|
||||||
|
"with_surface",
|
||||||
|
"with_highway",
|
||||||
|
"with_style",
|
||||||
|
"with_waypoint",
|
||||||
|
"with_tracks_and_segments",
|
||||||
|
"self_closing_points",
|
||||||
|
] {
|
||||||
|
let (file, again, _) = round_trip(name, ExportOptions::ALL);
|
||||||
|
assert_eq!(file.info.name, again.info.name, "{name}");
|
||||||
|
assert_eq!(file.info.desc, again.info.desc, "{name}");
|
||||||
|
assert_eq!(file.info.author, again.info.author, "{name}");
|
||||||
|
assert_eq!(file.trk.len(), again.trk.len(), "{name}");
|
||||||
|
for (a, b) in file.trk.iter().zip(&again.trk) {
|
||||||
|
assert_eq!(a.info, b.info, "{name}");
|
||||||
|
assert_eq!(a.trkseg.len(), b.trkseg.len(), "{name}");
|
||||||
|
}
|
||||||
|
same_points(&points(&file), &points(&again));
|
||||||
|
let waypoints: Vec<_> = file.wpt.iter().collect();
|
||||||
|
let again_waypoints: Vec<_> = again.wpt.iter().collect();
|
||||||
|
assert_eq!(waypoints.len(), again_waypoints.len(), "{name}");
|
||||||
|
for (a, b) in waypoints.iter().zip(&again_waypoints) {
|
||||||
|
assert!(a.same_content(b), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_options_leave_data_out() {
|
||||||
|
let (file, categories) = read("with_hr");
|
||||||
|
assert!(points(&file).iter().any(|p| p.hr.is_some()));
|
||||||
|
let options = ExportOptions {
|
||||||
|
hr: false,
|
||||||
|
..ExportOptions::ALL
|
||||||
|
};
|
||||||
|
let written = written_text(&file, &categories, options);
|
||||||
|
assert!(!written.contains("hr>"));
|
||||||
|
|
||||||
|
let (file, categories) = read("with_time");
|
||||||
|
let options = ExportOptions {
|
||||||
|
time: false,
|
||||||
|
..ExportOptions::ALL
|
||||||
|
};
|
||||||
|
assert!(!written_text(&file, &categories, options).contains("<time>"));
|
||||||
|
assert!(
|
||||||
|
written_text(&file, &categories, ExportOptions::ALL)
|
||||||
|
.contains("<time>2023-12-31T23:00:00.000Z</time>")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_is_escaped_and_the_single_track_gets_the_name_of_the_file() {
|
||||||
|
let (mut file, categories) = read("simple");
|
||||||
|
file.info.name = "a <b> & c".into();
|
||||||
|
file.trk.truncate(1);
|
||||||
|
file.trk[0].info.name = None;
|
||||||
|
let written = write(&file, &categories, ExportOptions::ALL);
|
||||||
|
let text = String::from_utf8(written.clone()).unwrap();
|
||||||
|
assert!(text.contains("a <b> & c"));
|
||||||
|
// the track has the name of the file
|
||||||
|
assert_eq!(text.matches("a <b> & c").count(), 2);
|
||||||
|
// and it is read back as it was
|
||||||
|
let mut categories = categories;
|
||||||
|
let again = parse(&written, &mut categories).unwrap();
|
||||||
|
assert_eq!(again.info.name, "a <b> & c");
|
||||||
|
assert_eq!(again.trk[0].info.name.as_deref(), Some("a <b> & c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exportable_data() {
|
||||||
|
let (file, _) = read("with_time");
|
||||||
|
let data = file.exportable_data();
|
||||||
|
assert!(data.time && !data.hr && !data.osm);
|
||||||
|
let (file, _) = read("with_surface");
|
||||||
|
assert!(file.exportable_data().osm);
|
||||||
|
let (file, _) = read("with_power_2");
|
||||||
|
assert!(file.exportable_data().power);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_route() -> ExportOptions {
|
||||||
|
ExportOptions {
|
||||||
|
as_route: true,
|
||||||
|
..ExportOptions::ALL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(text: &str, pattern: &str) -> usize {
|
||||||
|
text.matches(pattern).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_export_as_route_writes_routes_instead_of_tracks() {
|
||||||
|
let (file, categories) = read("with_route_points");
|
||||||
|
let text = written_text(&file, &categories, as_route());
|
||||||
|
|
||||||
|
assert_eq!(count(&text, "<rte>"), 1);
|
||||||
|
assert_eq!(count(&text, "<trk>"), 0);
|
||||||
|
assert_eq!(count(&text, "<trkseg>"), 0);
|
||||||
|
assert_eq!(count(&text, "<trkpt"), 0);
|
||||||
|
// the anchors are the route points, the trackpoints between them the points of their path
|
||||||
|
assert_eq!(count(&text, "<rtept"), 3);
|
||||||
|
assert_eq!(count(&text, "<gpxx:rpt"), 4);
|
||||||
|
assert_eq!(count(&text, "<gpxx:RoutePointExtension>"), 2);
|
||||||
|
// a route has the information of its track
|
||||||
|
assert!(text.contains("<name>Lausanne</name>"));
|
||||||
|
assert!(text.contains("<cmt>comment</cmt>"));
|
||||||
|
assert!(text.contains("<desc>description</desc>"));
|
||||||
|
assert!(text.contains("<type>Cycling</type>"));
|
||||||
|
// and its points only have a position in the path, and an elevation as route points
|
||||||
|
let first_rtept = &text[text.find("<rtept").unwrap()..];
|
||||||
|
let (lat, lng) = (46.54462809674442_f64, 6.658079791814089_f64);
|
||||||
|
assert!(first_rtept.starts_with(&format!("<rtept lat=\"{lat}\" lon=\"{lng}\">")));
|
||||||
|
assert!(first_rtept.contains("<ele>500</ele>"));
|
||||||
|
let (lat, lng) = (46.54441595077515_f64, 6.658358573913574_f64);
|
||||||
|
assert!(text.contains(&format!("<gpxx:rpt lat=\"{lat}\" lon=\"{lng}\"/>")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_route_survives_being_exported_as_a_route() {
|
||||||
|
let (file, mut categories) = read("with_route_points");
|
||||||
|
let written = write(&file, &categories, as_route());
|
||||||
|
let again = parse(&written, &mut categories).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(again.trk.len(), 1);
|
||||||
|
assert_eq!(again.trk[0].info, file.trk[0].info);
|
||||||
|
let (a, b) = (&file.trk[0].trkseg[0], &again.trk[0].trkseg[0]);
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
for (a, b) in a.iter().zip(b.iter()) {
|
||||||
|
assert_eq!(a.coordinates.lat, b.coordinates.lat);
|
||||||
|
assert_eq!(a.coordinates.lng, b.coordinates.lng);
|
||||||
|
assert_eq!(a.anchor, b.anchor);
|
||||||
|
}
|
||||||
|
// the elevation of the route points is kept, the other ones are worked out again
|
||||||
|
let anchors = |s: &crate::TrackSegment| {
|
||||||
|
s.iter()
|
||||||
|
.filter(|p| p.anchor.is_some())
|
||||||
|
.map(|p| p.ele)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(anchors(a), anchors(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tracks_become_one_route_per_segment_with_the_information_of_the_track() {
|
||||||
|
let (file, mut categories) = read("with_tracks_and_segments");
|
||||||
|
let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
|
||||||
|
assert!(segments > 2);
|
||||||
|
let written = write(&file, &categories, as_route());
|
||||||
|
let text = String::from_utf8(written.clone()).unwrap();
|
||||||
|
assert_eq!(count(&text, "<rte>"), segments);
|
||||||
|
assert_eq!(count(&text, "<trk>"), 0);
|
||||||
|
// every route starts and ends with a route point
|
||||||
|
assert_eq!(count(&text, "</rte>"), segments);
|
||||||
|
|
||||||
|
let again = parse(&written, &mut categories).unwrap();
|
||||||
|
assert_eq!(again.trk.len(), segments);
|
||||||
|
let mut routes = again.trk.iter();
|
||||||
|
for track in &file.trk {
|
||||||
|
for segment in &track.trkseg {
|
||||||
|
let route = routes.next().unwrap();
|
||||||
|
// the information of the track, repeated for each of its segments
|
||||||
|
assert_eq!(route.info.name, track.info.name);
|
||||||
|
assert_eq!(route.info.type_, track.info.type_);
|
||||||
|
let (a, b) = (segment, &route.trkseg[0]);
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
for (a, b) in a.iter().zip(b.iter()) {
|
||||||
|
assert_eq!(
|
||||||
|
(a.coordinates.lat, a.coordinates.lng),
|
||||||
|
(b.coordinates.lat, b.coordinates.lng)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// the anchors of the segment are the ones of its route
|
||||||
|
let anchors = |s: &crate::TrackSegment| {
|
||||||
|
s.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, p)| p.anchor.is_some())
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(anchors(a), anchors(b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_ends_of_a_route_are_route_points_even_without_anchors() {
|
||||||
|
let (mut file, categories) = read("simple");
|
||||||
|
file.trk[0].trkseg[0].update_all(|_, point| point.anchor = None);
|
||||||
|
let len = file.trk[0].trkseg[0].len();
|
||||||
|
let text = written_text(&file, &categories, as_route());
|
||||||
|
assert_eq!(count(&text, "<rtept"), 2);
|
||||||
|
assert_eq!(count(&text, "<gpxx:rpt"), len - 2);
|
||||||
|
// and a single trackpoint is a route of one point
|
||||||
|
file.trk[0].trkseg[0].splice(1, len, vec![]);
|
||||||
|
let text = written_text(&file, &categories, as_route());
|
||||||
|
assert_eq!(count(&text, "<rtept"), 1);
|
||||||
|
assert_eq!(count(&text, "<gpxx:rpt"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_segments_are_not_routes() {
|
||||||
|
let (mut file, categories) = read("simple");
|
||||||
|
file.trk[0].trkseg.push(crate::TrackSegment::default());
|
||||||
|
let text = written_text(&file, &categories, as_route());
|
||||||
|
assert_eq!(count(&text, "<rte>"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_routes_have_the_style_of_their_track() {
|
||||||
|
let (file, categories) = read("with_style");
|
||||||
|
let text = written_text(&file, &categories, as_route());
|
||||||
|
assert!(count(&text, "<gpx_style:line>") >= 1);
|
||||||
|
assert_eq!(count(&text, "<gpx_style:line>"), count(&text, "<rte>"));
|
||||||
|
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
|
||||||
|
assert_eq!(again.trk[0].info.color, file.trk[0].info.color);
|
||||||
|
assert_eq!(again.trk[0].info.width, file.trk[0].info.width);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_route_points_keep_their_data_and_the_options_apply_to_them() {
|
||||||
|
let (file, categories) = read("with_hr");
|
||||||
|
let with_all = written_text(&file, &categories, as_route());
|
||||||
|
assert!(with_all.contains("<gpxtpx:hr>"));
|
||||||
|
let without = written_text(
|
||||||
|
&file,
|
||||||
|
&categories,
|
||||||
|
ExportOptions {
|
||||||
|
hr: false,
|
||||||
|
time: false,
|
||||||
|
as_route: true,
|
||||||
|
..ExportOptions::ALL
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(!without.contains("<gpxtpx:hr>"));
|
||||||
|
assert!(!without.contains("<time>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_route_option_is_not_data() {
|
||||||
|
let (file, _) = read("with_route_points");
|
||||||
|
assert!(!file.exportable_data().as_route);
|
||||||
|
// it is a way to write the data, never part of them
|
||||||
|
let all = std::hint::black_box(ExportOptions::ALL);
|
||||||
|
assert!(!all.as_route && !ExportOptions::NONE.as_route);
|
||||||
|
assert!(as_route().union(ExportOptions::NONE).as_route);
|
||||||
|
assert!(!ExportOptions::ALL.union(ExportOptions::NONE).as_route);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
mod algorithm;
|
||||||
|
pub(crate) mod gpx;
|
||||||
|
mod io;
|
||||||
|
pub(crate) mod statistics;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
pub use algorithm::*;
|
||||||
|
pub use gpx::*;
|
||||||
|
pub use io::*;
|
||||||
|
pub use statistics::*;
|
||||||
|
pub use utils::*;
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
use crate::{
|
||||||
|
LngLat, LngLatBounds, TrackSegment, TrackSegmentIndex, Trackpoint, distance, for_each_window,
|
||||||
|
max_options, min_options, ramer_douglas_peucker, slope, speed, sum_options, time_diff,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Time between two trackpoints, to size the windows of a smoothing. It is unknown when one of
|
||||||
|
/// them has no time, which is longer than any known time span: the window does not go there.
|
||||||
|
#[derive(Clone, Copy, PartialEq, PartialOrd)]
|
||||||
|
enum TimeSpan {
|
||||||
|
Known(i64),
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimeSpan {
|
||||||
|
fn between(end: Option<i64>, start: Option<i64>) -> Self {
|
||||||
|
time_diff(end, start).map_or(Self::Unknown, Self::Known)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct Statistics {
|
||||||
|
pub global: GlobalStatistics,
|
||||||
|
pub local: Vec<TrackpointStatistics>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Statistics {
|
||||||
|
pub fn total_speed(&self) -> Option<f64> {
|
||||||
|
self.global.total_speed()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn moving_speed(&self) -> Option<f64> {
|
||||||
|
self.global.moving_speed()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute(trkseg: &TrackSegment) -> Self {
|
||||||
|
let mut stats = Self::default();
|
||||||
|
if trkseg.is_empty() {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut prev = &trkseg[0];
|
||||||
|
for cur in trkseg.iter() {
|
||||||
|
stats.accumulate(prev, cur);
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.compute_smoothed_speed(trkseg);
|
||||||
|
stats.compute_smoothed_elevation_segments(trkseg);
|
||||||
|
stats.compute_smoothed_slope(trkseg);
|
||||||
|
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accumulate(&mut self, prev: &Trackpoint, cur: &Trackpoint) {
|
||||||
|
self.accumulate_distance_and_time(prev, cur);
|
||||||
|
self.update_time_bounds(cur.time);
|
||||||
|
self.update_bounds(cur.coordinates);
|
||||||
|
self.local
|
||||||
|
.push(TrackpointStatistics::from_partial_stats(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accumulate_distance_and_time(&mut self, prev: &Trackpoint, cur: &Trackpoint) {
|
||||||
|
let dist = distance(prev.coordinates, cur.coordinates);
|
||||||
|
let time = time_diff(cur.time, prev.time);
|
||||||
|
|
||||||
|
self.global.total_distance += dist;
|
||||||
|
|
||||||
|
let moving = time
|
||||||
|
.zip(time.and_then(|time| speed(dist, time)))
|
||||||
|
.filter(|(_, speed)| (0.5..=1500.0).contains(speed));
|
||||||
|
if let Some((time, _)) = moving {
|
||||||
|
self.global.moving_distance = self
|
||||||
|
.global
|
||||||
|
.moving_distance
|
||||||
|
.map_or(Some(dist), |d| Some(d + dist));
|
||||||
|
self.global.moving_time = self
|
||||||
|
.global
|
||||||
|
.moving_time
|
||||||
|
.map_or(Some(time), |t| Some(t + time));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_time_bounds(&mut self, time: Option<i64>) {
|
||||||
|
if let Some(time) = time {
|
||||||
|
if self.global.start_time.is_none() {
|
||||||
|
self.global.start_time = Some(time);
|
||||||
|
}
|
||||||
|
self.global.end_time = Some(time);
|
||||||
|
self.global.total_time = time_diff(self.global.end_time, self.global.start_time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_bounds(&mut self, coordinates: LngLat) {
|
||||||
|
self.global.bounds.extend(coordinates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Speed over a window of 10 seconds on each side of the trackpoints that have a time (the
|
||||||
|
/// others have no speed). The windows do not extend to the trackpoints without time: if the
|
||||||
|
/// previous or next trackpoint has none, the window ends at the trackpoint itself on that side.
|
||||||
|
fn compute_smoothed_speed(&mut self, trkseg: &TrackSegment) {
|
||||||
|
for_each_window!(
|
||||||
|
trkseg,
|
||||||
|
trkseg.first_index(),
|
||||||
|
trkseg.last_index(),
|
||||||
|
TimeSpan::Known(10_000),
|
||||||
|
|i, j| TimeSpan::between(trkseg[j].time, trkseg[i].time),
|
||||||
|
|i, left, right| {
|
||||||
|
if trkseg[i].time.is_some() {
|
||||||
|
let timed = |index: TrackSegmentIndex| {
|
||||||
|
if trkseg[index].time.is_some() {
|
||||||
|
index
|
||||||
|
} else {
|
||||||
|
i
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (left, right) = (timed(left), timed(right));
|
||||||
|
self.local[i.flat].speed = time_diff(trkseg[right].time, trkseg[left].time)
|
||||||
|
.and_then(|time| {
|
||||||
|
speed(
|
||||||
|
self.local[right.flat].total_distance
|
||||||
|
- self.local[left.flat].total_distance,
|
||||||
|
time,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_smoothed_elevation_segments(&mut self, trkseg: &TrackSegment) {
|
||||||
|
let simplified = self.get_elevation_extremas(trkseg);
|
||||||
|
|
||||||
|
for i in 0..(simplified.len() - 1) {
|
||||||
|
let start = simplified[i];
|
||||||
|
let end = simplified[i + 1];
|
||||||
|
let last = i + 1 == simplified.len() - 1;
|
||||||
|
|
||||||
|
self.compute_smoothed_elevation_gain(trkseg, start, end, last);
|
||||||
|
|
||||||
|
let segment_dist =
|
||||||
|
self.local[end.flat].total_distance - self.local[start.flat].total_distance;
|
||||||
|
let segment_ele = trkseg[end].ele - trkseg[start].ele;
|
||||||
|
let segment_slope = slope(segment_ele, segment_dist);
|
||||||
|
for k in start.flat..(end.flat + last as usize) {
|
||||||
|
self.local[k].slope_segment = SlopeSegment {
|
||||||
|
slope: segment_slope,
|
||||||
|
distance: segment_dist,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_smoothed_elevation_gain(
|
||||||
|
&mut self,
|
||||||
|
trkseg: &TrackSegment,
|
||||||
|
start: TrackSegmentIndex,
|
||||||
|
end: TrackSegmentIndex,
|
||||||
|
last: bool,
|
||||||
|
) {
|
||||||
|
let mut cumul_ele = 0.0;
|
||||||
|
let mut current_left = start;
|
||||||
|
let mut current_right = Some(start);
|
||||||
|
let mut prev_smoothed_ele = trkseg[start].ele;
|
||||||
|
|
||||||
|
for_each_window!(
|
||||||
|
trkseg,
|
||||||
|
Some(start),
|
||||||
|
Some(end),
|
||||||
|
0.1,
|
||||||
|
|i, j| self.local[j.flat].total_distance - self.local[i.flat].total_distance,
|
||||||
|
|i, left, right| {
|
||||||
|
while current_left != left {
|
||||||
|
cumul_ele -= trkseg[current_left].ele;
|
||||||
|
current_left = trkseg.next_index(Some(current_left)).unwrap();
|
||||||
|
}
|
||||||
|
while let Some(current) = current_right {
|
||||||
|
if current > right {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cumul_ele += trkseg[current].ele;
|
||||||
|
current_right = trkseg.next_index(current_right);
|
||||||
|
}
|
||||||
|
|
||||||
|
let smoothed_ele = if i == start || i == end {
|
||||||
|
trkseg[i].ele
|
||||||
|
} else {
|
||||||
|
cumul_ele / (right.flat - left.flat + 1) as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
let delta = smoothed_ele - prev_smoothed_ele;
|
||||||
|
if delta > 0.0 {
|
||||||
|
self.global.elevation_gain += delta;
|
||||||
|
} else if delta < 0.0 {
|
||||||
|
self.global.elevation_loss -= delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < end || last {
|
||||||
|
self.local[i.flat].elevation_gain = self.global.elevation_gain;
|
||||||
|
self.local[i.flat].elevation_loss = self.global.elevation_loss;
|
||||||
|
}
|
||||||
|
|
||||||
|
prev_smoothed_ele = smoothed_ele;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_smoothed_slope(&mut self, trkseg: &TrackSegment) {
|
||||||
|
for_each_window!(
|
||||||
|
trkseg,
|
||||||
|
trkseg.first_index(),
|
||||||
|
trkseg.last_index(),
|
||||||
|
0.05,
|
||||||
|
|i, j| self.local[j.flat].total_distance - self.local[i.flat].total_distance,
|
||||||
|
|i, left, right| {
|
||||||
|
let dist =
|
||||||
|
self.local[right.flat].total_distance - self.local[left.flat].total_distance;
|
||||||
|
let ele = trkseg[right].ele - trkseg[left].ele;
|
||||||
|
self.local[i.flat].slope = slope(ele, dist);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_elevation_extremas(&self, trkseg: &TrackSegment) -> Vec<TrackSegmentIndex> {
|
||||||
|
ramer_douglas_peucker(
|
||||||
|
trkseg,
|
||||||
|
&|idx| {
|
||||||
|
(
|
||||||
|
self.local[idx.flat].total_distance * 1000.0,
|
||||||
|
trkseg[idx].ele,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
20.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct GlobalStatistics {
|
||||||
|
pub total_distance: f64,
|
||||||
|
pub moving_distance: Option<f64>,
|
||||||
|
pub total_time: Option<i64>,
|
||||||
|
pub moving_time: Option<i64>,
|
||||||
|
pub elevation_gain: f64,
|
||||||
|
pub elevation_loss: f64,
|
||||||
|
pub start_time: Option<i64>,
|
||||||
|
pub end_time: Option<i64>,
|
||||||
|
pub bounds: LngLatBounds,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GlobalStatistics {
|
||||||
|
/// Average speed over the total time, unknown if that is not a positive duration (for
|
||||||
|
/// example a range with a single timestamp, or timestamps going backwards).
|
||||||
|
pub fn total_speed(&self) -> Option<f64> {
|
||||||
|
self.total_time.and_then(|t| speed(self.total_distance, t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Average speed over the moving time, unknown if there is no moving time.
|
||||||
|
pub fn moving_speed(&self) -> Option<f64> {
|
||||||
|
self.moving_distance
|
||||||
|
.zip(self.moving_time)
|
||||||
|
.and_then(|(d, t)| speed(d, t))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merge(&mut self, other: &GlobalStatistics) {
|
||||||
|
self.total_distance += other.total_distance;
|
||||||
|
self.moving_distance = sum_options(self.moving_distance, other.moving_distance);
|
||||||
|
self.total_time = sum_options(self.total_time, other.total_time);
|
||||||
|
self.moving_time = sum_options(self.moving_time, other.moving_time);
|
||||||
|
self.elevation_gain += other.elevation_gain;
|
||||||
|
self.elevation_loss += other.elevation_loss;
|
||||||
|
self.start_time = min_options(self.start_time, other.start_time);
|
||||||
|
self.end_time = max_options(self.end_time, other.end_time);
|
||||||
|
self.bounds.merge(&other.bounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct TrackpointStatistics {
|
||||||
|
pub total_distance: f64,
|
||||||
|
pub moving_distance: Option<f64>,
|
||||||
|
pub total_time: Option<i64>,
|
||||||
|
pub moving_time: Option<i64>,
|
||||||
|
pub speed: Option<f64>,
|
||||||
|
pub elevation_gain: f64,
|
||||||
|
pub elevation_loss: f64,
|
||||||
|
pub slope: f64,
|
||||||
|
pub slope_segment: SlopeSegment,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackpointStatistics {
|
||||||
|
fn from_partial_stats(stats: &Statistics) -> Self {
|
||||||
|
Self {
|
||||||
|
total_distance: stats.global.total_distance,
|
||||||
|
moving_distance: stats.global.moving_distance,
|
||||||
|
total_time: stats.global.total_time,
|
||||||
|
moving_time: stats.global.moving_time,
|
||||||
|
// stats below are computed later
|
||||||
|
speed: None,
|
||||||
|
elevation_gain: Default::default(),
|
||||||
|
elevation_loss: Default::default(),
|
||||||
|
slope: Default::default(),
|
||||||
|
slope_segment: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct SlopeSegment {
|
||||||
|
pub slope: f64,
|
||||||
|
pub distance: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::{fs::File, io::Read};
|
||||||
|
|
||||||
|
use crate::parse;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_smoothed_speed() {
|
||||||
|
let mut f = File::open("data/with_time.gpx").unwrap();
|
||||||
|
let mut data = String::new();
|
||||||
|
let _ = f.read_to_string(&mut data);
|
||||||
|
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
|
||||||
|
|
||||||
|
let trkseg = &gpx.trk[0].trkseg[0];
|
||||||
|
let stats = Statistics::compute(trkseg);
|
||||||
|
assert_eq!(stats.local.len(), trkseg.len());
|
||||||
|
for trkpt_stats in stats.local.iter() {
|
||||||
|
assert!(trkpt_stats.speed.is_some());
|
||||||
|
let speed = trkpt_stats.speed.unwrap();
|
||||||
|
assert_ne!(speed, f64::NAN);
|
||||||
|
assert!((speed - 20.0).abs() < 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The speeds of the segment of `data/with_time.gpx` (80 trackpoints at 20 km/h) once the
|
||||||
|
/// trackpoints at `holes` have lost their time.
|
||||||
|
fn speeds_with_holes(holes: &[usize]) -> Vec<Option<f64>> {
|
||||||
|
let gpx = load("data/with_time.gpx");
|
||||||
|
let mut segment = gpx.trk[0].trkseg[0].clone();
|
||||||
|
let points: Vec<_> = segment
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, trkpt)| {
|
||||||
|
let mut trkpt = trkpt.clone();
|
||||||
|
if holes.contains(&i) {
|
||||||
|
trkpt.time = None;
|
||||||
|
}
|
||||||
|
trkpt
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
segment.splice(0, points.len(), points);
|
||||||
|
Statistics::compute(&segment)
|
||||||
|
.local
|
||||||
|
.iter()
|
||||||
|
.map(|local| local.speed)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Without time, there is no speed. With one, the speed is the one of the trackpoints around.
|
||||||
|
fn assert_speeds_with_holes(holes: &[usize]) {
|
||||||
|
let speeds = speeds_with_holes(holes);
|
||||||
|
assert_eq!(speeds.len(), 80);
|
||||||
|
for (i, speed) in speeds.iter().enumerate() {
|
||||||
|
if holes.contains(&i) {
|
||||||
|
assert_eq!(*speed, None, "trackpoint {i}");
|
||||||
|
} else {
|
||||||
|
let speed = speed.unwrap_or_else(|| panic!("no speed for trackpoint {i}"));
|
||||||
|
assert!((speed - 20.0).abs() < 0.1, "trackpoint {i}: {speed}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_speed_without_holes() {
|
||||||
|
assert_speeds_with_holes(&[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_speed_with_holes_at_the_beginning() {
|
||||||
|
assert_speeds_with_holes(&[0, 1, 2, 3, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_speed_with_holes_at_the_end() {
|
||||||
|
assert_speeds_with_holes(&[75, 76, 77, 78, 79]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_speed_with_holes_at_both_ends() {
|
||||||
|
assert_speeds_with_holes(&[0, 1, 2, 77, 78, 79]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_speed_with_holes_in_the_middle() {
|
||||||
|
assert_speeds_with_holes(&[40]);
|
||||||
|
assert_speeds_with_holes(&[30, 31, 32, 33, 34]);
|
||||||
|
assert_speeds_with_holes(&(20..60).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_speed_with_scattered_holes() {
|
||||||
|
let holes: Vec<_> = (0..80).step_by(7).collect();
|
||||||
|
assert_speeds_with_holes(&holes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_speed_without_any_time() {
|
||||||
|
let holes: Vec<_> = (0..80).collect();
|
||||||
|
assert!(speeds_with_holes(&holes).iter().all(Option::is_none));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(path: &str) -> crate::File {
|
||||||
|
let mut f = File::open(path).unwrap();
|
||||||
|
let mut data = String::new();
|
||||||
|
let _ = f.read_to_string(&mut data);
|
||||||
|
parse(data.as_bytes(), &mut Default::default()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_empty_segment() {
|
||||||
|
let stats = Statistics::compute(&TrackSegment::default());
|
||||||
|
assert!(stats.local.is_empty());
|
||||||
|
assert_eq!(stats.global.total_distance, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_distance_and_bounds() {
|
||||||
|
let gpx = load("data/simple.gpx");
|
||||||
|
let trkseg = &gpx.trk[0].trkseg[0];
|
||||||
|
let stats = Statistics::compute(trkseg);
|
||||||
|
|
||||||
|
assert_eq!(stats.local.len(), trkseg.len());
|
||||||
|
assert_eq!(stats.local[0].total_distance, 0.0);
|
||||||
|
assert!(stats.global.total_distance > 0.0);
|
||||||
|
// cumulative distance never decreases and ends at the global distance
|
||||||
|
assert!(
|
||||||
|
stats
|
||||||
|
.local
|
||||||
|
.windows(2)
|
||||||
|
.all(|w| w[0].total_distance <= w[1].total_distance)
|
||||||
|
);
|
||||||
|
let last = stats.local.last().unwrap();
|
||||||
|
assert!((last.total_distance - stats.global.total_distance).abs() < 1e-9);
|
||||||
|
assert!(stats.global.elevation_gain >= 0.0);
|
||||||
|
assert!(stats.global.elevation_loss >= 0.0);
|
||||||
|
|
||||||
|
// every point lies inside the bounds
|
||||||
|
let b = &stats.global.bounds;
|
||||||
|
for trkpt in trkseg.iter() {
|
||||||
|
assert!(b.sw.lng <= trkpt.coordinates.lng && trkpt.coordinates.lng <= b.ne.lng);
|
||||||
|
assert!(b.sw.lat <= trkpt.coordinates.lat && trkpt.coordinates.lat <= b.ne.lat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_time() {
|
||||||
|
let gpx = load("data/with_time.gpx");
|
||||||
|
let stats = Statistics::compute(&gpx.trk[0].trkseg[0]);
|
||||||
|
let total_time = stats.global.total_time.unwrap();
|
||||||
|
assert!(total_time > 0);
|
||||||
|
let speed = stats.global.total_speed().unwrap();
|
||||||
|
assert!((speed - 20.0).abs() < 0.5, "{speed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_time_without_timestamps() {
|
||||||
|
let gpx = load("data/simple.gpx");
|
||||||
|
let stats = Statistics::compute(&gpx.trk[0].trkseg[0]);
|
||||||
|
assert!(stats.global.total_time.is_none());
|
||||||
|
assert!(stats.global.total_speed().is_none());
|
||||||
|
assert!(stats.local.iter().all(|s| s.speed.is_none()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_global_merge() {
|
||||||
|
let mut a = GlobalStatistics {
|
||||||
|
total_distance: 1.0,
|
||||||
|
elevation_gain: 10.0,
|
||||||
|
moving_time: Some(5),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
a.bounds.extend(crate::LngLat { lng: 0.0, lat: 0.0 });
|
||||||
|
let mut b = GlobalStatistics {
|
||||||
|
total_distance: 2.0,
|
||||||
|
elevation_loss: 4.0,
|
||||||
|
moving_distance: Some(1.5),
|
||||||
|
moving_time: Some(7),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
b.bounds.extend(crate::LngLat { lng: 2.0, lat: 3.0 });
|
||||||
|
|
||||||
|
a.merge(&b);
|
||||||
|
assert_eq!(a.total_distance, 3.0);
|
||||||
|
assert_eq!(a.elevation_gain, 10.0);
|
||||||
|
assert_eq!(a.elevation_loss, 4.0);
|
||||||
|
assert_eq!(a.moving_distance, Some(1.5));
|
||||||
|
assert_eq!(a.moving_time, Some(12));
|
||||||
|
assert_eq!(a.total_time, None);
|
||||||
|
|
||||||
|
let mut c = GlobalStatistics {
|
||||||
|
start_time: Some(1_000),
|
||||||
|
end_time: Some(4_000),
|
||||||
|
total_time: Some(3_000),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let d = GlobalStatistics {
|
||||||
|
start_time: Some(10_000),
|
||||||
|
end_time: Some(12_000),
|
||||||
|
total_time: Some(2_000),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
c.merge(&d);
|
||||||
|
// 3 s + 2 s, the gap between the two is ignored
|
||||||
|
assert_eq!(c.total_time, Some(5_000));
|
||||||
|
let mut cumul = GlobalStatistics::default();
|
||||||
|
cumul.merge(&c);
|
||||||
|
cumul.merge(&d);
|
||||||
|
assert_eq!(cumul.start_time, Some(1_000));
|
||||||
|
assert_eq!(cumul.end_time, Some(12_000));
|
||||||
|
assert_eq!(cumul.total_time, Some(7_000));
|
||||||
|
assert_eq!((a.bounds.ne.lng, a.bounds.ne.lat), (2.0, 3.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mod compute;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
pub use compute::*;
|
||||||
|
pub use utils::*;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
use std::ops::Add;
|
||||||
|
|
||||||
|
pub fn combine_options<T>(a: Option<T>, b: Option<T>, f: impl FnOnce(T, T) -> T) -> Option<T> {
|
||||||
|
match (a, b) {
|
||||||
|
(Some(a), Some(b)) => Some(f(a, b)),
|
||||||
|
(Some(v), None) | (None, Some(v)) => Some(v),
|
||||||
|
(None, None) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sum_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
|
||||||
|
where
|
||||||
|
T: Add<Output = T>,
|
||||||
|
{
|
||||||
|
combine_options(a, b, |a, b| a + b)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn min_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
|
||||||
|
where
|
||||||
|
T: Ord,
|
||||||
|
{
|
||||||
|
combine_options(a, b, |a, b| a.min(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
|
||||||
|
where
|
||||||
|
T: Ord,
|
||||||
|
{
|
||||||
|
combine_options(a, b, |a, b| a.max(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sum_options() {
|
||||||
|
assert_eq!(sum_options(Some(1), Some(2)), Some(3));
|
||||||
|
assert_eq!(sum_options(Some(1), None), Some(1));
|
||||||
|
assert_eq!(sum_options(None, Some(2)), Some(2));
|
||||||
|
assert_eq!(sum_options::<i32>(None, None), None);
|
||||||
|
assert_eq!(sum_options(Some(0.5), Some(0.25)), Some(0.75));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
use std::f64::consts::PI;
|
||||||
|
|
||||||
|
use crate::LngLat;
|
||||||
|
|
||||||
|
const TO_RADIANS: f64 = PI / 180.0;
|
||||||
|
const EARTH_RADIUS: f64 = 6371.0088;
|
||||||
|
|
||||||
|
/// Computes the distance in kilometers between two coordinates using the Haversine formula
|
||||||
|
pub fn distance(p1: LngLat, p2: LngLat) -> f64 {
|
||||||
|
let lat1 = p1.lat * TO_RADIANS;
|
||||||
|
let lat2 = p2.lat * TO_RADIANS;
|
||||||
|
let delta_lat = lat2 - lat1;
|
||||||
|
let delta_lng = (p2.lng - p1.lng) * TO_RADIANS;
|
||||||
|
|
||||||
|
let a =
|
||||||
|
(delta_lat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (delta_lng / 2.0).sin().powi(2);
|
||||||
|
let c = 2.0 * a.min(1.0).sqrt().asin();
|
||||||
|
EARTH_RADIUS * c
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i64> {
|
||||||
|
Some(a? - b?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the speed in km/h for a given distance in kilometers and a time in milliseconds.
|
||||||
|
/// Unknown if the time is not a positive duration.
|
||||||
|
pub fn speed(distance: f64, time: i64) -> Option<f64> {
|
||||||
|
(time > 0).then(|| distance / (time as f64 / 3_600_000.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn slope(ele: f64, distance: f64) -> f64 {
|
||||||
|
if distance == 0.0 {
|
||||||
|
100.0
|
||||||
|
} else {
|
||||||
|
0.1 * ele / distance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const METERS_PER_LATITUDE_DEGREE: f64 = 111320.0;
|
||||||
|
|
||||||
|
/// Approximate planar projection, in meters, around the latitude of `origin` (ignores the
|
||||||
|
/// curvature of the earth).
|
||||||
|
struct Planar {
|
||||||
|
meters_per_longitude_degree: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Planar {
|
||||||
|
fn around(origin: LngLat) -> Self {
|
||||||
|
Self {
|
||||||
|
meters_per_longitude_degree: (origin.lat * TO_RADIANS).cos()
|
||||||
|
* METERS_PER_LATITUDE_DEGREE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_meters(&self, p: LngLat) -> (f64, f64) {
|
||||||
|
(
|
||||||
|
p.lng * self.meters_per_longitude_degree,
|
||||||
|
p.lat * METERS_PER_LATITUDE_DEGREE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_degrees(&self, (x, y): (f64, f64)) -> LngLat {
|
||||||
|
LngLat {
|
||||||
|
lng: x / self.meters_per_longitude_degree,
|
||||||
|
lat: y / METERS_PER_LATITUDE_DEGREE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position, between 0 (at `(x1, y1)`) and 1 (at `(x2, y2)`), of the point of the segment that is
|
||||||
|
/// the closest to `(x3, y3)`. A degenerate segment (same ends) is a point.
|
||||||
|
fn closest_on_segment(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> f64 {
|
||||||
|
let dx = x2 - x1;
|
||||||
|
let dy = y2 - y1;
|
||||||
|
let segment_length_squared = dx * dx + dy * dy;
|
||||||
|
if segment_length_squared == 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(((x3 - x1) * dx + (y3 - y1) * dy) / segment_length_squared).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the point on the line segment defined by p1 and p2 that is closest to the third
|
||||||
|
/// point, p3. Uses simple planar geometry (ignores earth curvature).
|
||||||
|
pub fn projected(p1: LngLat, p2: LngLat, p3: LngLat) -> LngLat {
|
||||||
|
let planar = Planar::around(p1);
|
||||||
|
let (x1, y1) = planar.to_meters(p1);
|
||||||
|
let (x2, y2) = planar.to_meters(p2);
|
||||||
|
let (x3, y3) = planar.to_meters(p3);
|
||||||
|
let t = closest_on_segment(x1, y1, x2, y2, x3, y3);
|
||||||
|
planar.to_degrees((x1 + t * (x2 - x1), y1 + t * (y2 - y1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the perpendicular distance in meters between a line segment (defined by p1 and p2)
|
||||||
|
/// and a third point, p3. Uses simple planar geometry (ignores earth curvature).
|
||||||
|
pub fn crossarc_lnglat(p1: LngLat, p2: LngLat, p3: LngLat) -> f64 {
|
||||||
|
let planar = Planar::around(p1);
|
||||||
|
let (x1, y1) = planar.to_meters(p1);
|
||||||
|
let (x2, y2) = planar.to_meters(p2);
|
||||||
|
let (x3, y3) = planar.to_meters(p3);
|
||||||
|
crossarc(x1, y1, x2, y2, x3, y3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distance from the point `(x3, y3)` to the segment `(x1, y1)`-`(x2, y2)`, in the units of the
|
||||||
|
/// coordinates (planar geometry).
|
||||||
|
pub fn crossarc(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> f64 {
|
||||||
|
let t = closest_on_segment(x1, y1, x2, y2, x3, y3);
|
||||||
|
(x3 - (x1 + t * (x2 - x1))).hypot(y3 - (y1 + t * (y2 - y1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn p(lng: f64, lat: f64) -> LngLat {
|
||||||
|
LngLat { lng, lat }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_time_diff_and_speed_with_long_durations() {
|
||||||
|
// 40 days in milliseconds, more than an i32 can hold
|
||||||
|
let forty_days = 40 * 24 * 3_600_000_i64;
|
||||||
|
assert!(forty_days > i64::from(i32::MAX));
|
||||||
|
assert_eq!(time_diff(Some(forty_days), Some(0)), Some(forty_days));
|
||||||
|
assert_eq!(time_diff(Some(0), Some(forty_days)), Some(-forty_days));
|
||||||
|
assert_eq!(time_diff(None, Some(1)), None);
|
||||||
|
assert_eq!(time_diff(Some(1), None), None);
|
||||||
|
// 1 km in one hour
|
||||||
|
assert!((speed(1.0, 3_600_000).unwrap() - 1.0).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_distance() {
|
||||||
|
assert_eq!(distance(p(4.0, 50.0), p(4.0, 50.0)), 0.0);
|
||||||
|
// one degree of latitude
|
||||||
|
let d = distance(p(0.0, 0.0), p(0.0, 1.0));
|
||||||
|
assert!((d - 111.195).abs() < 0.01, "{d}");
|
||||||
|
// symmetric
|
||||||
|
let a = p(4.40, 50.79);
|
||||||
|
let b = p(6.13, 45.90);
|
||||||
|
assert!((distance(a, b) - distance(b, a)).abs() < 1e-9);
|
||||||
|
// half the Earth's circumference, without NaN from rounding
|
||||||
|
let antipodal = distance(p(0.0, 0.0), p(180.0, 0.0));
|
||||||
|
assert!((antipodal - PI * EARTH_RADIUS).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_time_diff() {
|
||||||
|
assert_eq!(time_diff(Some(5000), Some(2000)), Some(3000));
|
||||||
|
assert_eq!(time_diff(Some(2000), Some(5000)), Some(-3000));
|
||||||
|
assert_eq!(time_diff(None, Some(1)), None);
|
||||||
|
assert_eq!(time_diff(Some(1), None), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_speed() {
|
||||||
|
// 1 km in 1 h
|
||||||
|
assert!((speed(1.0, 3_600_000).unwrap() - 1.0).abs() < 1e-12);
|
||||||
|
// 10 km in 30 min
|
||||||
|
assert!((speed(10.0, 1_800_000).unwrap() - 20.0).abs() < 1e-12);
|
||||||
|
// no duration, no speed
|
||||||
|
assert_eq!(speed(1.0, 0), None);
|
||||||
|
assert_eq!(speed(0.0, 0), None);
|
||||||
|
assert_eq!(speed(1.0, -1000), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_slope() {
|
||||||
|
// 10 m of elevation over 100 m (distance is expressed in km, hence the 0.1 factor)
|
||||||
|
assert!((slope(10.0, 0.1) - 10.0).abs() < 1e-12);
|
||||||
|
assert!((slope(-5.0, 0.1) + 5.0).abs() < 1e-12);
|
||||||
|
assert_eq!(slope(0.0, 1.0), 0.0);
|
||||||
|
assert_eq!(slope(10.0, 0.0), 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crossarc() {
|
||||||
|
// perpendicular distance to the segment (0,0)-(10,0)
|
||||||
|
assert!((crossarc(0.0, 0.0, 10.0, 0.0, 5.0, 3.0) - 3.0).abs() < 1e-12);
|
||||||
|
// beyond the ends: distance to the closest endpoint
|
||||||
|
assert!((crossarc(0.0, 0.0, 10.0, 0.0, 13.0, 4.0) - 5.0).abs() < 1e-12);
|
||||||
|
assert!((crossarc(0.0, 0.0, 10.0, 0.0, -3.0, 4.0) - 5.0).abs() < 1e-12);
|
||||||
|
// point on the segment
|
||||||
|
assert_eq!(crossarc(0.0, 0.0, 10.0, 0.0, 4.0, 0.0), 0.0);
|
||||||
|
// degenerate segment
|
||||||
|
assert!((crossarc(1.0, 1.0, 1.0, 1.0, 4.0, 5.0) - 5.0).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_projected() {
|
||||||
|
let proj = projected(p(0.0, 0.0), p(1.0, 0.0), p(0.5, 1.0));
|
||||||
|
assert!((proj.lng - 0.5).abs() < 1e-9);
|
||||||
|
assert!(proj.lat.abs() < 1e-9);
|
||||||
|
// clamped to the segment
|
||||||
|
let proj = projected(p(0.0, 0.0), p(1.0, 0.0), p(2.0, 1.0));
|
||||||
|
assert!((proj.lng - 1.0).abs() < 1e-9);
|
||||||
|
// degenerate segment
|
||||||
|
let proj = projected(p(3.0, 4.0), p(3.0, 4.0), p(5.0, 6.0));
|
||||||
|
assert_eq!((proj.lng, proj.lat), (3.0, 4.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crossarc_lnglat() {
|
||||||
|
// about 1 degree of latitude away from an east-west segment on the equator
|
||||||
|
let d = crossarc_lnglat(p(0.0, 0.0), p(1.0, 0.0), p(0.5, 1.0));
|
||||||
|
assert!((d - METERS_PER_LATITUDE_DEGREE).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use crate::{Command, FileId, SelectMode, Selection};
|
||||||
|
|
||||||
|
/// Everything the frontend can ask the engine to do.
|
||||||
|
///
|
||||||
|
/// Only [`Action::Edit`] changes the files (and records an undo step); the others act on the
|
||||||
|
/// history, on the selection or on the file order.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Action<'a> {
|
||||||
|
Edit(Command<'a>),
|
||||||
|
Undo,
|
||||||
|
Redo,
|
||||||
|
/// Selects files, tracks, segments or waypoints (or the waypoints node of one file),
|
||||||
|
/// ignoring what does not exist: a selection left with nothing is empty, which deselects
|
||||||
|
/// everything. See [`SelectMode`] for how it combines with the current selection.
|
||||||
|
Select {
|
||||||
|
selection: Selection,
|
||||||
|
mode: SelectMode,
|
||||||
|
},
|
||||||
|
/// Selects all the elements of the same kind as the selected ones, in the same place (see
|
||||||
|
/// [`Selection::all_at_level`]). Does nothing when there is nothing more to select.
|
||||||
|
SelectAll,
|
||||||
|
/// Moves the selection to the next (`down`) or previous element of the same kind, like the
|
||||||
|
/// arrow keys do in the file list (see [`Selection::neighbour`]). With `add`, the element is
|
||||||
|
/// added to the selection (shift + arrow), otherwise it replaces it.
|
||||||
|
ArrowSelect {
|
||||||
|
down: bool,
|
||||||
|
add: bool,
|
||||||
|
},
|
||||||
|
/// Puts the selected elements in the clipboard, to be pasted by [`crate::Paste`]. Does
|
||||||
|
/// nothing when nothing is selected.
|
||||||
|
Copy,
|
||||||
|
/// Like `Copy`, but the elements are moved instead of copied when they are pasted.
|
||||||
|
Cut,
|
||||||
|
/// Moves the files to `index` among the other files, in the given order. Not undoable.
|
||||||
|
Reorder {
|
||||||
|
file_ids: Vec<FileId>,
|
||||||
|
index: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use crate::State;
|
||||||
|
|
||||||
|
/// The effect of a command on the engine state.
|
||||||
|
///
|
||||||
|
/// Each command is a small struct holding its arguments, with its effect implemented in its
|
||||||
|
/// own file. Effects are built from the reusable patterns in `pattern/`.
|
||||||
|
pub trait Apply {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CommandError {
|
||||||
|
/// The command would not change anything.
|
||||||
|
NothingToDo,
|
||||||
|
/// The input data could not be parsed.
|
||||||
|
InvalidData(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CommandError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NothingToDo => write!(f, "nothing to do"),
|
||||||
|
Self::InvalidData(err) => write!(f, "invalid data: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CommandError {}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Metadata<'a> {
|
||||||
|
pub name: &'a str,
|
||||||
|
pub desc: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for Metadata<'_> {
|
||||||
|
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
update_selected(state, &mut self);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Editor for Metadata<'_> {
|
||||||
|
fn file(&mut self, file: &mut File) -> Edit {
|
||||||
|
file.info.name = self.name.to_owned();
|
||||||
|
file.info.desc = Some(self.desc.to_owned());
|
||||||
|
// a single track is named like the file
|
||||||
|
if let [track] = file.trk.as_mut_slice() {
|
||||||
|
track.info.name = Some(self.name.to_owned());
|
||||||
|
}
|
||||||
|
Edit::Changed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track(&mut self, track: &mut Track) -> Edit {
|
||||||
|
track.info.name = Some(self.name.to_owned());
|
||||||
|
track.info.desc = Some(self.desc.to_owned());
|
||||||
|
Edit::Changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::{New, NewTrack, Selection, engine::command::fixture::Fixture};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_metadata_applies_to_selected_file() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
New {
|
||||||
|
name: "first",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
New {
|
||||||
|
name: "second",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap(); // now selected
|
||||||
|
let selected = *fx.selected_files().iter().next().unwrap();
|
||||||
|
|
||||||
|
Metadata {
|
||||||
|
name: "renamed",
|
||||||
|
desc: "description",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for (id, file) in fx.files.iter() {
|
||||||
|
if *id == selected {
|
||||||
|
assert_eq!(file.info.name, "renamed");
|
||||||
|
assert_eq!(file.info.desc.as_deref(), Some("description"));
|
||||||
|
} else {
|
||||||
|
assert_eq!(file.info.name, "first");
|
||||||
|
assert_eq!(file.info.desc, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_metadata_applies_to_selected_track() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let data = std::fs::read("data/with_tracks.gpx").unwrap();
|
||||||
|
crate::Load {
|
||||||
|
data: &data,
|
||||||
|
name: "file",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let file = fx.files.values().next().unwrap().clone();
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: file.id,
|
||||||
|
trk_ids: HashSet::from([file.trk[0].id]),
|
||||||
|
};
|
||||||
|
|
||||||
|
Metadata {
|
||||||
|
name: "renamed",
|
||||||
|
desc: "",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let after = &fx.files[&file.id];
|
||||||
|
assert_eq!(after.info.name, file.info.name);
|
||||||
|
assert_eq!(after.trk[0].info.name.as_deref(), Some("renamed"));
|
||||||
|
assert_eq!(after.trk[1].info, file.trk[1].info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new file with `nb_tracks` tracks (without name), selected.
|
||||||
|
fn file_with_tracks(fx: &mut Fixture, nb_tracks: usize) {
|
||||||
|
New {
|
||||||
|
name: "file",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
for _ in 0..nb_tracks {
|
||||||
|
NewTrack.apply(&mut fx.state()).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename(fx: &mut Fixture) {
|
||||||
|
Metadata {
|
||||||
|
name: "renamed",
|
||||||
|
desc: "",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_single_track_is_renamed_like_the_file() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
file_with_tracks(&mut fx, 1);
|
||||||
|
rename(&mut fx);
|
||||||
|
let file = fx.files.values().next().unwrap();
|
||||||
|
assert_eq!(file.info.name, "renamed");
|
||||||
|
assert_eq!(file.trk[0].info.name.as_deref(), Some("renamed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_single_track_with_a_name_is_renamed_too() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
file_with_tracks(&mut fx, 1);
|
||||||
|
let id = *fx.files.keys().next().unwrap();
|
||||||
|
std::rc::Rc::make_mut(fx.files.get_mut(&id).unwrap()).trk[0]
|
||||||
|
.info
|
||||||
|
.name = Some("track".to_owned());
|
||||||
|
rename(&mut fx);
|
||||||
|
let file = &fx.files[&id];
|
||||||
|
assert_eq!(file.info.name, "renamed");
|
||||||
|
assert_eq!(file.trk[0].info.name.as_deref(), Some("renamed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_several_tracks_are_not_renamed() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
file_with_tracks(&mut fx, 2);
|
||||||
|
rename(&mut fx);
|
||||||
|
let file = fx.files.values().next().unwrap();
|
||||||
|
assert_eq!(file.info.name, "renamed");
|
||||||
|
assert!(file.trk.iter().all(|trk| trk.info.name.is_none()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
mod metadata;
|
||||||
|
mod new_track;
|
||||||
|
mod new_track_segment;
|
||||||
|
mod style;
|
||||||
|
|
||||||
|
pub use metadata::*;
|
||||||
|
pub use new_track::*;
|
||||||
|
pub use new_track_segment::*;
|
||||||
|
pub use style::*;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct NewTrack;
|
||||||
|
|
||||||
|
impl Apply for NewTrack {
|
||||||
|
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
update_selected(state, &mut self);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Editor for NewTrack {
|
||||||
|
fn file(&mut self, file: &mut File) -> Edit {
|
||||||
|
let mut track = Track::default();
|
||||||
|
track.info.color = common(file, |t| &t.info.color);
|
||||||
|
track.info.opacity = common(file, |t| &t.info.opacity);
|
||||||
|
track.info.width = common(file, |t| &t.info.width);
|
||||||
|
file.trk.push(track);
|
||||||
|
Edit::Changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the value of a style attribute if all existing tracks agree on it.
|
||||||
|
fn common<T: Clone + PartialEq>(file: &File, get: impl Fn(&Track) -> &Option<T>) -> Option<T> {
|
||||||
|
let mut tracks = file.trk.iter();
|
||||||
|
let first = get(tracks.next()?);
|
||||||
|
tracks.all(|t| get(t) == first).then(|| first.clone())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::{New, engine::command::fixture::Fixture};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_track() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
New {
|
||||||
|
name: "file",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
NewTrack.apply(&mut fx.state()).unwrap();
|
||||||
|
NewTrack.apply(&mut fx.state()).unwrap();
|
||||||
|
let file = fx.files.values().next().unwrap();
|
||||||
|
assert_eq!(file.trk.len(), 2);
|
||||||
|
assert_ne!(file.trk[0].id, file.trk[1].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_track_inherits_agreeing_style_only() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
New {
|
||||||
|
name: "file",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
NewTrack.apply(&mut fx.state()).unwrap();
|
||||||
|
NewTrack.apply(&mut fx.state()).unwrap();
|
||||||
|
let file = std::rc::Rc::make_mut(fx.files.values_mut().next().unwrap());
|
||||||
|
for (i, trk) in file.trk.iter_mut().enumerate() {
|
||||||
|
trk.info.color = Some("ff0000".into());
|
||||||
|
trk.info.opacity = Some(0.5 + i as f64 * 0.1);
|
||||||
|
}
|
||||||
|
NewTrack.apply(&mut fx.state()).unwrap();
|
||||||
|
let info = &fx.files.values().next().unwrap().trk[2].info;
|
||||||
|
assert_eq!(info.color.as_deref(), Some("ff0000"));
|
||||||
|
assert_eq!(info.opacity, None);
|
||||||
|
assert_eq!(info.width, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
use crate::{Apply, CommandError, Edit, Editor, State, Track, TrackSegment, update_selected};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct NewTrackSegment;
|
||||||
|
|
||||||
|
impl Apply for NewTrackSegment {
|
||||||
|
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
update_selected(state, &mut self);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Editor for NewTrackSegment {
|
||||||
|
fn track(&mut self, track: &mut Track) -> Edit {
|
||||||
|
track.trkseg.push(TrackSegment::default());
|
||||||
|
Edit::Changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::{New, NewTrack, engine::command::fixture::Fixture};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_track_segment_on_each_track_of_selected_file() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
New {
|
||||||
|
name: "file",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
NewTrack.apply(&mut fx.state()).unwrap();
|
||||||
|
NewTrack.apply(&mut fx.state()).unwrap();
|
||||||
|
NewTrackSegment.apply(&mut fx.state()).unwrap();
|
||||||
|
let file = fx.files.values().next().unwrap();
|
||||||
|
assert!(file.trk.iter().all(|t| t.trkseg.len() == 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
use crate::{Apply, CommandError, Edit, Editor, State, Track, update_selected};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Style<'a> {
|
||||||
|
pub color: Option<&'a str>,
|
||||||
|
pub opacity: Option<f64>,
|
||||||
|
pub width: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for Style<'_> {
|
||||||
|
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
update_selected(state, &mut self);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Editor for Style<'_> {
|
||||||
|
fn track(&mut self, track: &mut Track) -> Edit {
|
||||||
|
if let Some(color) = self.color {
|
||||||
|
track.info.color = Some(color.to_owned());
|
||||||
|
}
|
||||||
|
if self.opacity.is_some() {
|
||||||
|
track.info.opacity = self.opacity;
|
||||||
|
}
|
||||||
|
if self.width.is_some() {
|
||||||
|
track.info.width = self.width;
|
||||||
|
}
|
||||||
|
Edit::Changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::{Selection, engine::command::fixture::Fixture};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_style_applies_to_all_tracks_of_selected_file_and_keeps_unset_fields() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let data = std::fs::read("data/with_style.gpx").unwrap();
|
||||||
|
crate::Load {
|
||||||
|
data: &data,
|
||||||
|
name: "file",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let id = *fx.files.keys().next().unwrap();
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
assert!(!before.trk.is_empty());
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
};
|
||||||
|
|
||||||
|
Style {
|
||||||
|
color: Some("ff0000"),
|
||||||
|
opacity: None,
|
||||||
|
width: Some(7.0),
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for (b, a) in before.trk.iter().zip(fx.files[&id].trk.iter()) {
|
||||||
|
assert_eq!(a.info.color.as_deref(), Some("ff0000"));
|
||||||
|
assert_eq!(a.info.width, Some(7.0));
|
||||||
|
assert_eq!(a.info.opacity, b.info.opacity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{Apply, CommandError, FileId, Files, Selection, State, Waypoint};
|
||||||
|
|
||||||
|
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements
|
||||||
|
/// are deleted instead, even if only a track or a waypoint is selected.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Delete {
|
||||||
|
pub whole_files: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for Delete {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let files = state.selection.to_files();
|
||||||
|
let selection = if self.whole_files {
|
||||||
|
&files
|
||||||
|
} else {
|
||||||
|
&*state.selection
|
||||||
|
};
|
||||||
|
let next = match selection {
|
||||||
|
Selection::File { file_ids } => {
|
||||||
|
state.files.retain(|id, _| !file_ids.contains(id));
|
||||||
|
state.order.0.retain(|id| !file_ids.contains(id));
|
||||||
|
Selection::Empty
|
||||||
|
}
|
||||||
|
Selection::Track { file_id, trk_ids } => {
|
||||||
|
let file = state
|
||||||
|
.files
|
||||||
|
.get_mut(file_id)
|
||||||
|
.ok_or(CommandError::NothingToDo)?;
|
||||||
|
let file = Rc::make_mut(file);
|
||||||
|
let len = file.trk.len();
|
||||||
|
file.trk.retain(|trk| !trk_ids.contains(&trk.id));
|
||||||
|
if file.trk.len() == len {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
Selection::File {
|
||||||
|
file_ids: [*file_id].into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Selection::TrackSegment {
|
||||||
|
file_id,
|
||||||
|
trk_id,
|
||||||
|
trkseg_ids,
|
||||||
|
} => {
|
||||||
|
let file = state
|
||||||
|
.files
|
||||||
|
.get_mut(file_id)
|
||||||
|
.ok_or(CommandError::NothingToDo)?;
|
||||||
|
let file = Rc::make_mut(file);
|
||||||
|
let trk = file
|
||||||
|
.trk
|
||||||
|
.iter_mut()
|
||||||
|
.find(|trk| trk.id == *trk_id)
|
||||||
|
.ok_or(CommandError::NothingToDo)?;
|
||||||
|
let len = trk.trkseg.len();
|
||||||
|
trk.trkseg.retain(|seg| !trkseg_ids.contains(&seg.id));
|
||||||
|
if trk.trkseg.len() == len {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
Selection::Track {
|
||||||
|
file_id: *file_id,
|
||||||
|
trk_ids: [*trk_id].into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Selection::Waypoints { file_id } => delete_waypoints(state.files, *file_id, |_| true)?,
|
||||||
|
Selection::Waypoint { file_id, wpt_ids } => {
|
||||||
|
delete_waypoints(state.files, *file_id, |wpt| wpt_ids.contains(&wpt.id))?
|
||||||
|
}
|
||||||
|
Selection::Empty => return Err(CommandError::NothingToDo),
|
||||||
|
};
|
||||||
|
*state.selection = next;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn delete_waypoints(
|
||||||
|
files: &mut Files,
|
||||||
|
file_id: FileId,
|
||||||
|
filter: impl Fn(&Waypoint) -> bool,
|
||||||
|
) -> Result<Selection, CommandError> {
|
||||||
|
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||||
|
let mut file = (**file).clone();
|
||||||
|
let changed = file.wpt.edit(&filter, |wpts| {
|
||||||
|
wpts.retain(|wpt| !filter(wpt));
|
||||||
|
true
|
||||||
|
});
|
||||||
|
if !changed {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
files.insert(file_id, Rc::new(file));
|
||||||
|
Ok(Selection::File {
|
||||||
|
file_ids: [file_id].into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::engine::command::fixture::Fixture;
|
||||||
|
use crate::{FileId, Load};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn loaded() -> (Fixture, FileId) {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
|
||||||
|
Load {
|
||||||
|
data: &data,
|
||||||
|
name: "file",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let id = fx.order.0[0];
|
||||||
|
(fx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_nothing_selected() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
assert_eq!(
|
||||||
|
Delete { whole_files: false }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_selected_files() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
crate::New {
|
||||||
|
name: "keep",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let keep = fx.order.0[1];
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
};
|
||||||
|
Delete { whole_files: false }
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fx.order.0, vec![keep]);
|
||||||
|
assert!(fx.files.contains_key(&keep) && !fx.files.contains_key(&id));
|
||||||
|
assert!(matches!(fx.selection, Selection::Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_selected_tracks() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
let before = fx.files[&id].trk.len();
|
||||||
|
let trk_id = fx.files[&id].trk[0].id;
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: HashSet::from([trk_id]),
|
||||||
|
};
|
||||||
|
Delete { whole_files: false }
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let file = &fx.files[&id];
|
||||||
|
assert_eq!(file.trk.len(), before - 1);
|
||||||
|
assert!(file.trk.iter().all(|t| t.id != trk_id));
|
||||||
|
assert_eq!(fx.selected_files(), HashSet::from([id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_selected_segments() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
let trk = &fx.files[&id].trk[0];
|
||||||
|
let (trk_id, seg_id, before) = (trk.id, trk.trkseg[0].id, trk.trkseg.len());
|
||||||
|
fx.selection = Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id,
|
||||||
|
trkseg_ids: HashSet::from([seg_id]),
|
||||||
|
};
|
||||||
|
Delete { whole_files: false }
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let trk = &fx.files[&id].trk[0];
|
||||||
|
assert_eq!(trk.trkseg.len(), before - 1);
|
||||||
|
assert!(trk.trkseg.iter().all(|s| s.id != seg_id));
|
||||||
|
assert!(
|
||||||
|
matches!(&fx.selection, Selection::Track { trk_ids, .. } if trk_ids.contains(&trk_id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_waypoints(fx: &mut Fixture, id: FileId, n: usize) -> Vec<crate::WaypointId> {
|
||||||
|
let wpts: Vec<_> = (0..n).map(|_| Waypoint::default()).collect();
|
||||||
|
let ids = wpts.iter().map(|w| w.id).collect();
|
||||||
|
let mut file = (*fx.files[&id]).clone();
|
||||||
|
file.wpt = crate::Waypoints::new([crate::WaypointChunk {
|
||||||
|
wpt: wpts,
|
||||||
|
..Default::default()
|
||||||
|
}]);
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_selected_waypoints() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
let ids = with_waypoints(&mut fx, id, 3);
|
||||||
|
fx.selection = Selection::Waypoint {
|
||||||
|
file_id: id,
|
||||||
|
wpt_ids: HashSet::from([ids[1]]),
|
||||||
|
};
|
||||||
|
let rev = fx.files[&id].wpt.rev_id;
|
||||||
|
Delete { whole_files: false }
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(fx.files[&id].wpt.rev_id, rev);
|
||||||
|
let left: Vec<_> = fx.files[&id].wpt.iter().map(|w| w.id).collect();
|
||||||
|
assert_eq!(left, vec![ids[0], ids[2]]);
|
||||||
|
assert_eq!(fx.selected_files(), HashSet::from([id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_all_waypoints_of_file() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
with_waypoints(&mut fx, id, 3);
|
||||||
|
fx.selection = Selection::Waypoints { file_id: id };
|
||||||
|
Delete { whole_files: false }
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert!(fx.files[&id].wpt.is_empty());
|
||||||
|
fx.selection = Selection::Waypoints { file_id: id };
|
||||||
|
assert_eq!(
|
||||||
|
Delete { whole_files: false }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_unknown_ids_is_nothing_to_do() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: HashSet::from([Default::default()]),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
Delete { whole_files: false }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_whole_files_of_the_selected_elements() {
|
||||||
|
for selection in [
|
||||||
|
|id: FileId, fx: &Fixture| Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: HashSet::from([fx.files[&id].trk[0].id]),
|
||||||
|
},
|
||||||
|
|id: FileId, fx: &Fixture| Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: fx.files[&id].trk[0].id,
|
||||||
|
trkseg_ids: HashSet::from([fx.files[&id].trk[0].trkseg[0].id]),
|
||||||
|
},
|
||||||
|
|id: FileId, _: &Fixture| Selection::Waypoints { file_id: id },
|
||||||
|
|id: FileId, _: &Fixture| Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
crate::New {
|
||||||
|
name: "keep",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let keep = fx.order.0[1];
|
||||||
|
fx.selection = selection(id, &fx);
|
||||||
|
Delete { whole_files: true }.apply(&mut fx.state()).unwrap();
|
||||||
|
assert_eq!(fx.order.0, vec![keep]);
|
||||||
|
assert!(!fx.files.contains_key(&id));
|
||||||
|
assert!(matches!(fx.selection, Selection::Empty));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_whole_files_nothing_selected() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
assert_eq!(
|
||||||
|
Delete { whole_files: true }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
use crate::{Apply, CommandError, Selection, State};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DeleteAll;
|
||||||
|
|
||||||
|
impl Apply for DeleteAll {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
if state.files.is_empty() {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
state.files.clear();
|
||||||
|
state.order.0.clear();
|
||||||
|
*state.selection = Selection::Empty;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::engine::command::fixture::Fixture;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_all() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
assert_eq!(
|
||||||
|
DeleteAll.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
crate::New {
|
||||||
|
name: "a",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
crate::New {
|
||||||
|
name: "b",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert!(DeleteAll.apply(&mut fx.state()).is_ok());
|
||||||
|
assert!(fx.files.is_empty());
|
||||||
|
assert!(fx.order.0.is_empty());
|
||||||
|
assert!(matches!(fx.selection, Selection::Empty));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
use crate::{Apply, CommandError, FileId, Selection, State, WaypointId, delete_waypoints};
|
||||||
|
|
||||||
|
/// Deletes a waypoint of a file, whatever is selected (what the delete button of its popup does).
|
||||||
|
/// If it was the only selected element, its file gets selected.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DeleteWaypoint {
|
||||||
|
pub file_id: FileId,
|
||||||
|
pub waypoint_id: WaypointId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for DeleteWaypoint {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
delete_waypoints(state.files, self.file_id, |wpt| wpt.id == self.waypoint_id)?;
|
||||||
|
if let Selection::Waypoint { file_id, wpt_ids } = &*state.selection
|
||||||
|
&& *file_id == self.file_id
|
||||||
|
&& wpt_ids.iter().all(|id| *id == self.waypoint_id)
|
||||||
|
{
|
||||||
|
*state.selection = Selection::File {
|
||||||
|
file_ids: [self.file_id].into(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
File, Selection, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deletes_one_waypoint_without_touching_the_selection() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let mut file = File::default();
|
||||||
|
file.wpt.push(WaypointChunk {
|
||||||
|
wpt: (0..3)
|
||||||
|
.map(|i| Waypoint {
|
||||||
|
name: Some(format!("w{i}")),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let id = file.id;
|
||||||
|
let ids: Vec<_> = waypoint_ids(&file).collect();
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
fx.order.0.push(id);
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: [id].into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
DeleteWaypoint {
|
||||||
|
file_id: id,
|
||||||
|
waypoint_id: ids[1],
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let names: Vec<_> = fx.files[&id]
|
||||||
|
.wpt
|
||||||
|
.iter()
|
||||||
|
.map(|wpt| wpt.name.clone().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(names, ["w0", "w2"]);
|
||||||
|
assert_eq!(
|
||||||
|
fx.selection,
|
||||||
|
Selection::File {
|
||||||
|
file_ids: [id].into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deleting_the_selected_waypoint_selects_its_file() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let mut file = File::default();
|
||||||
|
file.wpt.push(WaypointChunk {
|
||||||
|
wpt: vec![Waypoint::default(), Waypoint::default()],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let id = file.id;
|
||||||
|
let ids: Vec<_> = waypoint_ids(&file).collect();
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
fx.selection = Selection::Waypoint {
|
||||||
|
file_id: id,
|
||||||
|
wpt_ids: [ids[0]].into(),
|
||||||
|
};
|
||||||
|
DeleteWaypoint {
|
||||||
|
file_id: id,
|
||||||
|
waypoint_id: ids[0],
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fx.selection,
|
||||||
|
Selection::File {
|
||||||
|
file_ids: [id].into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unknown_waypoint_is_nothing_to_do() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let file = File::default();
|
||||||
|
let id = file.id;
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
let result = DeleteWaypoint {
|
||||||
|
file_id: id,
|
||||||
|
waypoint_id: WaypointId::default(),
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state());
|
||||||
|
assert!(matches!(result, Err(CommandError::NothingToDo)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
use std::{collections::HashSet, rc::Rc};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Apply, CommandError, FileId, Files, Selection, State, Waypoint, copy_file, copy_segment,
|
||||||
|
copy_track, copy_waypoint,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Duplicate;
|
||||||
|
|
||||||
|
impl Apply for Duplicate {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let next = match &*state.selection {
|
||||||
|
Selection::File { file_ids } => {
|
||||||
|
let mut copies = HashSet::new();
|
||||||
|
let mut order = Vec::new();
|
||||||
|
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
|
||||||
|
let Some(file) = state.files.get(id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let copy = copy_file(file);
|
||||||
|
order.push(copy.id);
|
||||||
|
copies.insert(copy.id);
|
||||||
|
state.files.insert(copy.id, Rc::new(copy));
|
||||||
|
}
|
||||||
|
if copies.is_empty() {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
let at = state
|
||||||
|
.order
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.rposition(|id| file_ids.contains(id))
|
||||||
|
.map_or(state.order.0.len(), |i| i + 1);
|
||||||
|
state.order.0.splice(at..at, order);
|
||||||
|
Selection::File { file_ids: copies }
|
||||||
|
}
|
||||||
|
Selection::Track { file_id, trk_ids } => {
|
||||||
|
let file = state
|
||||||
|
.files
|
||||||
|
.get_mut(file_id)
|
||||||
|
.ok_or(CommandError::NothingToDo)?;
|
||||||
|
let file = Rc::make_mut(file);
|
||||||
|
let mut copies = HashSet::new();
|
||||||
|
append_copies(
|
||||||
|
&mut file.trk,
|
||||||
|
|trk| trk_ids.contains(&trk.id),
|
||||||
|
|trk| {
|
||||||
|
let copy = copy_track(trk);
|
||||||
|
copies.insert(copy.id);
|
||||||
|
copy
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if copies.is_empty() {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
Selection::Track {
|
||||||
|
file_id: *file_id,
|
||||||
|
trk_ids: copies,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Selection::TrackSegment {
|
||||||
|
file_id,
|
||||||
|
trk_id,
|
||||||
|
trkseg_ids,
|
||||||
|
} => {
|
||||||
|
let file = state
|
||||||
|
.files
|
||||||
|
.get_mut(file_id)
|
||||||
|
.ok_or(CommandError::NothingToDo)?;
|
||||||
|
let file = Rc::make_mut(file);
|
||||||
|
let trk = file
|
||||||
|
.trk
|
||||||
|
.iter_mut()
|
||||||
|
.find(|trk| trk.id == *trk_id)
|
||||||
|
.ok_or(CommandError::NothingToDo)?;
|
||||||
|
let mut copies = HashSet::new();
|
||||||
|
append_copies(
|
||||||
|
&mut trk.trkseg,
|
||||||
|
|seg| trkseg_ids.contains(&seg.id),
|
||||||
|
|seg| {
|
||||||
|
let copy = copy_segment(seg);
|
||||||
|
copies.insert(copy.id);
|
||||||
|
copy
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if copies.is_empty() {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
Selection::TrackSegment {
|
||||||
|
file_id: *file_id,
|
||||||
|
trk_id: *trk_id,
|
||||||
|
trkseg_ids: copies,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Selection::Waypoints { file_id } => {
|
||||||
|
duplicate_waypoints(state.files, *file_id, |_| true)?
|
||||||
|
}
|
||||||
|
Selection::Waypoint { file_id, wpt_ids } => {
|
||||||
|
duplicate_waypoints(state.files, *file_id, |wpt| wpt_ids.contains(&wpt.id))?
|
||||||
|
}
|
||||||
|
Selection::Empty => return Err(CommandError::NothingToDo),
|
||||||
|
};
|
||||||
|
*state.selection = next;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duplicate_waypoints(
|
||||||
|
files: &mut Files,
|
||||||
|
file_id: FileId,
|
||||||
|
filter: impl Fn(&Waypoint) -> bool,
|
||||||
|
) -> Result<Selection, CommandError> {
|
||||||
|
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||||
|
let selected: Vec<&Waypoint> = file.wpt.iter().filter(|wpt| filter(wpt)).collect();
|
||||||
|
let last = selected.last().ok_or(CommandError::NothingToDo)?.id;
|
||||||
|
let copies: Vec<Waypoint> = selected.into_iter().map(copy_waypoint).collect();
|
||||||
|
let copy_ids = copies.iter().map(|wpt| wpt.id).collect();
|
||||||
|
|
||||||
|
// The copies go right after the last selected waypoint.
|
||||||
|
let mut file = (**file).clone();
|
||||||
|
file.wpt.insert_after(Some(last), copies);
|
||||||
|
files.insert(file_id, Rc::new(file));
|
||||||
|
Ok(Selection::Waypoint {
|
||||||
|
file_id,
|
||||||
|
wpt_ids: copy_ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts a copy of each item matching `filter`, as a block after the last matching item.
|
||||||
|
fn append_copies<T>(items: &mut Vec<T>, filter: impl Fn(&T) -> bool, copy: impl FnMut(&T) -> T) {
|
||||||
|
let Some(last) = items.iter().rposition(&filter) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let copies: Vec<T> = items.iter().filter(|item| filter(item)).map(copy).collect();
|
||||||
|
items.splice(last + 1..last + 1, copies);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::engine::command::fixture::Fixture;
|
||||||
|
use crate::{FileId, Load};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn loaded() -> (Fixture, FileId) {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
|
||||||
|
Load {
|
||||||
|
data: &data,
|
||||||
|
name: "file",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let id = fx.order.0[0];
|
||||||
|
(fx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_duplicate_nothing_selected() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
assert_eq!(
|
||||||
|
Duplicate.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_duplicate_file() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
Duplicate.apply(&mut fx.state()).unwrap();
|
||||||
|
assert_eq!(fx.files.len(), 2);
|
||||||
|
let copy_id = fx.order.0[1];
|
||||||
|
assert_eq!(fx.selected_files(), [copy_id].into());
|
||||||
|
let (orig, copy) = (&fx.files[&id], &fx.files[©_id]);
|
||||||
|
assert_ne!(copy_id, id);
|
||||||
|
assert_eq!(copy.info.name, orig.info.name);
|
||||||
|
assert_eq!(copy.trk.len(), orig.trk.len());
|
||||||
|
for (a, b) in orig.trk.iter().zip(©.trk) {
|
||||||
|
assert_ne!(a.id, b.id);
|
||||||
|
assert_eq!(a.info, b.info);
|
||||||
|
for (a, b) in a.trkseg.iter().zip(&b.trkseg) {
|
||||||
|
assert_ne!(a.id, b.id);
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_duplicate_files_go_after_last_selected() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
for name in ["a", "b", "c"] {
|
||||||
|
crate::New {
|
||||||
|
name,
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let [a, b, c] = [fx.order.0[0], fx.order.0[1], fx.order.0[2]];
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: [a, b].into(),
|
||||||
|
};
|
||||||
|
Duplicate.apply(&mut fx.state()).unwrap();
|
||||||
|
assert_eq!(fx.order.0.len(), 5);
|
||||||
|
assert_eq!(fx.order.0[..2], [a, b]);
|
||||||
|
assert_eq!(fx.order.0[4], c);
|
||||||
|
assert_eq!(
|
||||||
|
fx.selected_files(),
|
||||||
|
fx.order.0[2..4].iter().copied().collect()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_duplicate_track_appends_copy() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
let before = fx.files[&id].trk.len();
|
||||||
|
let (trk_id, second_id) = (fx.files[&id].trk[0].id, fx.files[&id].trk[1].id);
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: [trk_id].into(),
|
||||||
|
};
|
||||||
|
Duplicate.apply(&mut fx.state()).unwrap();
|
||||||
|
let file = &fx.files[&id];
|
||||||
|
assert_eq!(file.trk.len(), before + 1);
|
||||||
|
assert_eq!(file.trk[0].id, trk_id);
|
||||||
|
assert_ne!(file.trk[1].id, trk_id);
|
||||||
|
// copies go right after the selected track, not at the end
|
||||||
|
assert_eq!(file.trk[2].id, second_id);
|
||||||
|
assert!(
|
||||||
|
matches!(&fx.selection, Selection::Track { trk_ids, .. } if trk_ids.contains(&file.trk[1].id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_duplicate_selected_waypoints() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect();
|
||||||
|
let ids: Vec<_> = wpts.iter().map(|w| w.id).collect();
|
||||||
|
let mut file = (*fx.files[&id]).clone();
|
||||||
|
file.wpt = crate::Waypoints::new([crate::WaypointChunk {
|
||||||
|
wpt: wpts,
|
||||||
|
..Default::default()
|
||||||
|
}]);
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
let original_chunk = fx.files[&id].wpt.chunks()[0].clone();
|
||||||
|
|
||||||
|
fx.selection = Selection::Waypoint {
|
||||||
|
file_id: id,
|
||||||
|
wpt_ids: HashSet::from([ids[0]]),
|
||||||
|
};
|
||||||
|
let rev = fx.files[&id].wpt.rev_id;
|
||||||
|
Duplicate.apply(&mut fx.state()).unwrap();
|
||||||
|
assert_ne!(fx.files[&id].wpt.rev_id, rev);
|
||||||
|
let all: Vec<_> = fx.files[&id].wpt.iter().map(|w| w.id).collect();
|
||||||
|
assert_eq!(all.len(), 4);
|
||||||
|
assert!(
|
||||||
|
matches!(&fx.selection, Selection::Waypoint { wpt_ids, .. } if wpt_ids == &HashSet::from([all[1]]))
|
||||||
|
);
|
||||||
|
// the copy comes right after the selected waypoint, in a new chunk
|
||||||
|
assert_eq!(all[..], [ids[0], all[1], ids[1], ids[2]]);
|
||||||
|
assert_eq!(fx.files[&id].wpt.chunks().len(), 1);
|
||||||
|
assert_ne!(fx.files[&id].wpt.chunks()[0].id, original_chunk.id);
|
||||||
|
|
||||||
|
// selecting the last waypoint: the copy goes at the end
|
||||||
|
let last = fx.files[&id].wpt.iter().last().unwrap().id;
|
||||||
|
fx.selection = Selection::Waypoint {
|
||||||
|
file_id: id,
|
||||||
|
wpt_ids: HashSet::from([last]),
|
||||||
|
};
|
||||||
|
Duplicate.apply(&mut fx.state()).unwrap();
|
||||||
|
let all: Vec<_> = fx.files[&id].wpt.iter().map(|w| w.id).collect();
|
||||||
|
assert_eq!(all.len(), 5);
|
||||||
|
assert_eq!(all[..4], [ids[0], all[1], ids[1], ids[2]]);
|
||||||
|
|
||||||
|
fx.selection = Selection::Waypoints { file_id: id };
|
||||||
|
Duplicate.apply(&mut fx.state()).unwrap();
|
||||||
|
assert_eq!(fx.files[&id].wpt.len(), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_duplicate_segment_appends_copy() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
let trk = &fx.files[&id].trk[0];
|
||||||
|
let (trk_id, seg_id, before) = (trk.id, trk.trkseg[0].id, trk.trkseg.len());
|
||||||
|
fx.selection = Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id,
|
||||||
|
trkseg_ids: [seg_id].into(),
|
||||||
|
};
|
||||||
|
Duplicate.apply(&mut fx.state()).unwrap();
|
||||||
|
let trk = &fx.files[&id].trk[0];
|
||||||
|
assert_eq!(trk.trkseg.len(), before + 1);
|
||||||
|
assert_eq!(trk.trkseg[0].id, seg_id);
|
||||||
|
assert_ne!(trk.trkseg[1].id, seg_id);
|
||||||
|
assert_eq!(trk.trkseg[0].len(), trk.trkseg[1].len());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
use crate::{Apply, CommandError, File, State, TrackpointCategories, parse, produce};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Load<'a> {
|
||||||
|
pub data: &'a [u8],
|
||||||
|
/// Name of the file when the data has none (typically the name of the file on disk, without
|
||||||
|
/// its extension).
|
||||||
|
pub name: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Load<'_> {
|
||||||
|
pub(crate) fn parse(self, categories: &mut TrackpointCategories) -> Result<File, CommandError> {
|
||||||
|
let mut file = parse(self.data, categories)
|
||||||
|
.map_err(|err| CommandError::InvalidData(err.to_string()))?;
|
||||||
|
if file.info.name.trim().is_empty() {
|
||||||
|
file.info.name = self.name.to_owned();
|
||||||
|
}
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for Load<'_> {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
LoadFiles { files: vec![self] }.apply(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads several files at once, as a single command (one undo step): the files are added at the
|
||||||
|
/// end of the list, in the given order, and the first one is selected.
|
||||||
|
///
|
||||||
|
/// The files that cannot be read are skipped, unless none of them can: then nothing is loaded
|
||||||
|
/// and the error is the one of the first file.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LoadFiles<'a> {
|
||||||
|
pub files: Vec<Load<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for LoadFiles<'_> {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let mut files = Vec::with_capacity(self.files.len());
|
||||||
|
let mut first_error = None;
|
||||||
|
for load in self.files {
|
||||||
|
match load.parse(state.categories) {
|
||||||
|
Ok(file) => files.push(file),
|
||||||
|
Err(error) => {
|
||||||
|
first_error.get_or_insert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if files.is_empty() {
|
||||||
|
return Err(first_error.unwrap_or(CommandError::NothingToDo));
|
||||||
|
}
|
||||||
|
produce(state, |_| files);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::engine::command::fixture::Fixture;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let data = std::fs::read("data/with_tracks.gpx").unwrap();
|
||||||
|
assert!(
|
||||||
|
Load {
|
||||||
|
data: &data,
|
||||||
|
name: "file",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert_eq!(fx.files.len(), 1);
|
||||||
|
let file = fx.files.values().next().unwrap();
|
||||||
|
assert!(!file.trk.is_empty());
|
||||||
|
assert_eq!(fx.selected_files(), [file.id].into());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_invalid_file_changes_nothing() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
crate::New {
|
||||||
|
name: "keep",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let selected = fx.selected_files();
|
||||||
|
|
||||||
|
let result = Load {
|
||||||
|
data: b"<gpx><trk></gpx>",
|
||||||
|
name: "file",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state());
|
||||||
|
assert!(matches!(result, Err(CommandError::InvalidData(_))));
|
||||||
|
assert_eq!(fx.files.len(), 1);
|
||||||
|
assert_eq!(fx.order.0.len(), 1);
|
||||||
|
assert_eq!(fx.selected_files(), selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_with_name(data: &[u8]) -> String {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
Load {
|
||||||
|
data,
|
||||||
|
name: "from disk",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
fx.files.values().next().unwrap().info.name.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_keeps_the_name_of_the_file() {
|
||||||
|
let data = br#"<gpx version="1.1"><metadata><name>in the file</name></metadata></gpx>"#;
|
||||||
|
assert_eq!(load_with_name(data), "in the file");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_without_name_uses_the_given_name() {
|
||||||
|
let without = br#"<gpx version="1.1"><trk><trkseg></trkseg></trk></gpx>"#;
|
||||||
|
assert_eq!(load_with_name(without), "from disk");
|
||||||
|
let empty = br#"<gpx version="1.1"><metadata><name> </name></metadata></gpx>"#;
|
||||||
|
assert_eq!(load_with_name(empty), "from disk");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gpx(name: &str) -> Vec<u8> {
|
||||||
|
format!(r#"<gpx version="1.1"><metadata><name>{name}</name></metadata></gpx>"#).into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load<'a>(data: &'a [u8], name: &'a str) -> Load<'a> {
|
||||||
|
Load { data, name }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_several_files() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
crate::New {
|
||||||
|
name: "before",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let before = fx.order.0[0];
|
||||||
|
let (a, b, c) = (gpx("a"), gpx("b"), gpx("c"));
|
||||||
|
|
||||||
|
LoadFiles {
|
||||||
|
files: vec![load(&a, "x"), load(&b, "x"), load(&c, "x")],
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// added after the others, in the order they were given
|
||||||
|
let names: Vec<_> = fx
|
||||||
|
.order
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|id| fx.files[id].info.name.clone())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(names, ["before", "a", "b", "c"]);
|
||||||
|
assert_eq!(fx.order.0[0], before);
|
||||||
|
// the first one is selected
|
||||||
|
assert_eq!(fx.selected_files(), [fx.order.0[1]].into());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_several_files_skips_the_invalid_ones() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let (a, c) = (gpx("a"), gpx("c"));
|
||||||
|
LoadFiles {
|
||||||
|
files: vec![load(&a, "x"), load(b"<gpx><trk></gpx>", "x"), load(&c, "x")],
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let names: Vec<_> = fx
|
||||||
|
.order
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|id| fx.files[id].info.name.clone())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(names, ["a", "c"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_several_invalid_files_changes_nothing() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
crate::New {
|
||||||
|
name: "keep",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let selected = fx.selected_files();
|
||||||
|
|
||||||
|
let result = LoadFiles {
|
||||||
|
files: vec![
|
||||||
|
load(b"<gpx><trk></gpx>", "x"),
|
||||||
|
load(b"<gpx><wpt></gpx>", "y"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state());
|
||||||
|
assert!(matches!(result, Err(CommandError::InvalidData(_))));
|
||||||
|
assert_eq!(fx.files.len(), 1);
|
||||||
|
assert_eq!(fx.order.0.len(), 1);
|
||||||
|
assert_eq!(fx.selected_files(), selected);
|
||||||
|
|
||||||
|
// nothing to load
|
||||||
|
assert_eq!(
|
||||||
|
LoadFiles { files: vec![] }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_each_file_without_name_gets_its_own_name() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let unnamed = br#"<gpx version="1.1"><trk><trkseg></trkseg></trk></gpx>"#;
|
||||||
|
LoadFiles {
|
||||||
|
files: vec![load(unnamed, "first"), load(unnamed, "second")],
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let names: Vec<_> = fx
|
||||||
|
.order
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|id| fx.files[id].info.name.clone())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(names, ["first", "second"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
mod delete;
|
||||||
|
mod delete_all;
|
||||||
|
mod delete_waypoint;
|
||||||
|
mod duplicate;
|
||||||
|
mod load;
|
||||||
|
mod move_elements;
|
||||||
|
mod new;
|
||||||
|
mod paste;
|
||||||
|
mod sync;
|
||||||
|
mod transfer;
|
||||||
|
|
||||||
|
pub use delete::*;
|
||||||
|
pub use delete_all::*;
|
||||||
|
pub use delete_waypoint::*;
|
||||||
|
pub use duplicate::*;
|
||||||
|
pub use load::*;
|
||||||
|
pub use move_elements::*;
|
||||||
|
pub use new::*;
|
||||||
|
pub use paste::*;
|
||||||
|
pub use sync::*;
|
||||||
|
pub use transfer::*;
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
use crate::{
|
||||||
|
Apply, Clipboard, CommandError, Destination, FileId, Place, Selection, State, TrackId, transfer,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Where moved elements go: a list of the file tree, and the position in it. The position is
|
||||||
|
/// counted among the elements of the list that are not moved, like the position of a dragged
|
||||||
|
/// element in the list it is dropped in.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MoveTarget {
|
||||||
|
/// The list of files.
|
||||||
|
Files { index: usize },
|
||||||
|
/// The tracks of a file.
|
||||||
|
Tracks { file_id: FileId, index: usize },
|
||||||
|
/// The segments of a track.
|
||||||
|
Segments {
|
||||||
|
file_id: FileId,
|
||||||
|
trk_id: TrackId,
|
||||||
|
index: usize,
|
||||||
|
},
|
||||||
|
/// The waypoints of a file.
|
||||||
|
Waypoints { file_id: FileId, index: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves elements to a place of the file tree (what drag and drop does), with their ids. The
|
||||||
|
/// moved elements are selected.
|
||||||
|
///
|
||||||
|
/// What can go where:
|
||||||
|
/// - files: among the files;
|
||||||
|
/// - tracks: among the files (each track becomes a file), or among the tracks of a file;
|
||||||
|
/// - segments: among the files (each segment becomes a file), among the tracks of a file (each
|
||||||
|
/// segment becomes a track), or among the segments of a track;
|
||||||
|
/// - waypoints, or all the waypoints of a file (the waypoints node): among the waypoints of a
|
||||||
|
/// file.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Move {
|
||||||
|
pub what: Selection,
|
||||||
|
pub to: MoveTarget,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MoveTarget> for Destination {
|
||||||
|
fn from(target: MoveTarget) -> Self {
|
||||||
|
match target {
|
||||||
|
MoveTarget::Files { index } => Destination::Files(Place::Index(index)),
|
||||||
|
MoveTarget::Tracks { file_id, index } => {
|
||||||
|
Destination::Tracks(file_id, Place::Index(index))
|
||||||
|
}
|
||||||
|
MoveTarget::Segments {
|
||||||
|
file_id,
|
||||||
|
trk_id,
|
||||||
|
index,
|
||||||
|
} => Destination::Segments(file_id, trk_id, Place::Index(index)),
|
||||||
|
MoveTarget::Waypoints { file_id, index } => {
|
||||||
|
Destination::Waypoints(file_id, Place::Index(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for Move {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let clipboard = Clipboard::new(&self.what, state.files, &state.order.0, true)
|
||||||
|
.ok_or(CommandError::NothingToDo)?;
|
||||||
|
*state.selection = transfer(state, &clipboard.content, true, self.to.into())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
File, Track, TrackInfo, TrackSegment, TrackSegmentId, Waypoint, WaypointChunk, WaypointId,
|
||||||
|
Waypoints, engine::command::fixture::Fixture, new_file, waypoint_ids,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A file with the given tracks (name, number of segments) and number of waypoints.
|
||||||
|
fn file(name: &str, tracks: &[(&str, usize)], waypoints: usize) -> File {
|
||||||
|
let mut file = new_file(
|
||||||
|
name,
|
||||||
|
tracks
|
||||||
|
.iter()
|
||||||
|
.map(|(name, segments)| Track {
|
||||||
|
info: TrackInfo {
|
||||||
|
name: Some((*name).to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
trkseg: (0..*segments).map(|_| TrackSegment::default()).collect(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
if waypoints > 0 {
|
||||||
|
file.wpt = Waypoints::new([WaypointChunk {
|
||||||
|
wpt: (0..waypoints).map(|_| Waypoint::default()).collect(),
|
||||||
|
..Default::default()
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
file
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(fx: &mut Fixture, file: File) -> FileId {
|
||||||
|
let id = file.id;
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
fx.order.0.push(id);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_ids(fx: &Fixture, id: FileId) -> Vec<TrackId> {
|
||||||
|
fx.files[&id].trk.iter().map(|trk| trk.id).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment_ids(fx: &Fixture, id: FileId, trk: usize) -> Vec<TrackSegmentId> {
|
||||||
|
fx.files[&id].trk[trk].trkseg.iter().map(|s| s.id).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wpt_ids(fx: &Fixture, id: FileId) -> Vec<WaypointId> {
|
||||||
|
waypoint_ids(&fx.files[&id]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_move(fx: &mut Fixture, what: Selection, to: MoveTarget) -> Result<(), CommandError> {
|
||||||
|
Move { what, to }.apply(&mut fx.state())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tracks(file_id: FileId, ids: &[TrackId]) -> Selection {
|
||||||
|
Selection::Track {
|
||||||
|
file_id,
|
||||||
|
trk_ids: ids.iter().copied().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segments(file_id: FileId, trk_id: TrackId, ids: &[TrackSegmentId]) -> Selection {
|
||||||
|
Selection::TrackSegment {
|
||||||
|
file_id,
|
||||||
|
trk_id,
|
||||||
|
trkseg_ids: ids.iter().copied().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn waypoints(file_id: FileId, ids: &[WaypointId]) -> Selection {
|
||||||
|
Selection::Waypoint {
|
||||||
|
file_id,
|
||||||
|
wpt_ids: ids.iter().copied().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn files(ids: &[FileId]) -> Selection {
|
||||||
|
Selection::File {
|
||||||
|
file_ids: ids.iter().copied().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reorder_tracks_in_a_file() {
|
||||||
|
let build = || {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(
|
||||||
|
&mut fx,
|
||||||
|
file("a", &[("0", 1), ("1", 1), ("2", 1), ("3", 1)], 0),
|
||||||
|
);
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
(fx, a, t)
|
||||||
|
};
|
||||||
|
let at = |a, index| MoveTarget::Tracks { file_id: a, index };
|
||||||
|
|
||||||
|
// the position counts the tracks that stay
|
||||||
|
let (mut fx, a, t) = build();
|
||||||
|
do_move(&mut fx, tracks(a, &[t[0]]), at(a, 1)).unwrap();
|
||||||
|
assert_eq!(track_ids(&fx, a), vec![t[1], t[0], t[2], t[3]]);
|
||||||
|
assert_eq!(fx.selection, tracks(a, &[t[0]]));
|
||||||
|
|
||||||
|
let (mut fx, a, t) = build();
|
||||||
|
do_move(&mut fx, tracks(a, &[t[3]]), at(a, 0)).unwrap();
|
||||||
|
assert_eq!(track_ids(&fx, a), vec![t[3], t[0], t[1], t[2]]);
|
||||||
|
|
||||||
|
// several at once, as a block, in their order; past the end is the end
|
||||||
|
let (mut fx, a, t) = build();
|
||||||
|
do_move(&mut fx, tracks(a, &[t[1], t[0]]), at(a, 99)).unwrap();
|
||||||
|
assert_eq!(track_ids(&fx, a), vec![t[2], t[3], t[0], t[1]]);
|
||||||
|
assert_eq!(fx.selection, tracks(a, &[t[0], t[1]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_move_tracks_to_another_file_or_to_new_files() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[("t0", 1), ("t1", 1)], 0));
|
||||||
|
let b = add(&mut fx, file("b", &[("b0", 1), ("b1", 1)], 0));
|
||||||
|
let c = add(&mut fx, file("c", &[], 0));
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
let b_tracks = track_ids(&fx, b);
|
||||||
|
|
||||||
|
do_move(
|
||||||
|
&mut fx,
|
||||||
|
tracks(a, &[t[0]]),
|
||||||
|
MoveTarget::Tracks {
|
||||||
|
file_id: b,
|
||||||
|
index: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(track_ids(&fx, a), vec![t[1]]);
|
||||||
|
assert_eq!(track_ids(&fx, b), vec![b_tracks[0], t[0], b_tracks[1]]);
|
||||||
|
assert_eq!(fx.selection, tracks(b, &[t[0]]));
|
||||||
|
|
||||||
|
// as a new file, at a position of the list of files
|
||||||
|
do_move(&mut fx, tracks(a, &[t[1]]), MoveTarget::Files { index: 1 }).unwrap();
|
||||||
|
assert!(track_ids(&fx, a).is_empty());
|
||||||
|
assert_eq!(fx.order.0.len(), 4);
|
||||||
|
let new = fx.order.0[1];
|
||||||
|
assert_eq!(fx.order.0[0], a);
|
||||||
|
assert_eq!(fx.order.0[2..], [b, c]);
|
||||||
|
assert_eq!(track_ids(&fx, new), vec![t[1]]);
|
||||||
|
assert_eq!(fx.files[&new].info.name, "t1");
|
||||||
|
assert_eq!(fx.selection, files(&[new]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_move_segments() {
|
||||||
|
let build = || {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[("t0", 3), ("t1", 1)], 0));
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
(fx, a, t)
|
||||||
|
};
|
||||||
|
|
||||||
|
// within a track
|
||||||
|
let (mut fx, a, t) = build();
|
||||||
|
let s = segment_ids(&fx, a, 0);
|
||||||
|
do_move(
|
||||||
|
&mut fx,
|
||||||
|
segments(a, t[0], &[s[2]]),
|
||||||
|
MoveTarget::Segments {
|
||||||
|
file_id: a,
|
||||||
|
trk_id: t[0],
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(segment_ids(&fx, a, 0), vec![s[2], s[0], s[1]]);
|
||||||
|
assert_eq!(fx.selection, segments(a, t[0], &[s[2]]));
|
||||||
|
|
||||||
|
// to another track
|
||||||
|
let (mut fx, a, t) = build();
|
||||||
|
let s = segment_ids(&fx, a, 0);
|
||||||
|
let other = segment_ids(&fx, a, 1);
|
||||||
|
do_move(
|
||||||
|
&mut fx,
|
||||||
|
segments(a, t[0], &[s[0], s[1]]),
|
||||||
|
MoveTarget::Segments {
|
||||||
|
file_id: a,
|
||||||
|
trk_id: t[1],
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(segment_ids(&fx, a, 0), vec![s[2]]);
|
||||||
|
assert_eq!(segment_ids(&fx, a, 1), vec![s[0], s[1], other[0]]);
|
||||||
|
|
||||||
|
// as new tracks of a file
|
||||||
|
let (mut fx, a, t) = build();
|
||||||
|
let s = segment_ids(&fx, a, 0);
|
||||||
|
do_move(
|
||||||
|
&mut fx,
|
||||||
|
segments(a, t[0], &[s[1]]),
|
||||||
|
MoveTarget::Tracks {
|
||||||
|
file_id: a,
|
||||||
|
index: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fx.files[&a].trk.len(), 3);
|
||||||
|
assert_eq!(segment_ids(&fx, a, 1), vec![s[1]]);
|
||||||
|
assert_eq!(segment_ids(&fx, a, 0), vec![s[0], s[2]]);
|
||||||
|
assert_eq!(fx.files[&a].trk[0].id, t[0]);
|
||||||
|
assert_eq!(fx.files[&a].trk[2].id, t[1]);
|
||||||
|
|
||||||
|
// as new files
|
||||||
|
let (mut fx, a, t) = build();
|
||||||
|
let s = segment_ids(&fx, a, 0);
|
||||||
|
do_move(
|
||||||
|
&mut fx,
|
||||||
|
segments(a, t[0], &[s[0], s[1]]),
|
||||||
|
MoveTarget::Files { index: 0 },
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fx.order.0.len(), 3);
|
||||||
|
assert_eq!(fx.order.0[2], a);
|
||||||
|
assert_eq!(fx.files[&fx.order.0[0]].info.name, "t0");
|
||||||
|
assert_eq!(segment_ids(&fx, a, 0), vec![s[2]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_move_waypoints() {
|
||||||
|
let build = || {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[], 4));
|
||||||
|
let b = add(&mut fx, file("b", &[], 2));
|
||||||
|
(fx, a, b)
|
||||||
|
};
|
||||||
|
let at = |file_id, index| MoveTarget::Waypoints { file_id, index };
|
||||||
|
|
||||||
|
// within a file
|
||||||
|
let (mut fx, a, _) = build();
|
||||||
|
let w = wpt_ids(&fx, a);
|
||||||
|
do_move(&mut fx, waypoints(a, &[w[3]]), at(a, 1)).unwrap();
|
||||||
|
assert_eq!(wpt_ids(&fx, a), vec![w[0], w[3], w[1], w[2]]);
|
||||||
|
assert_eq!(fx.selection, waypoints(a, &[w[3]]));
|
||||||
|
|
||||||
|
// to the start of another file
|
||||||
|
let (mut fx, a, b) = build();
|
||||||
|
let w = wpt_ids(&fx, a);
|
||||||
|
let before = wpt_ids(&fx, b);
|
||||||
|
do_move(&mut fx, waypoints(a, &[w[0], w[1]]), at(b, 0)).unwrap();
|
||||||
|
assert_eq!(wpt_ids(&fx, a), w[2..].to_vec());
|
||||||
|
assert_eq!(wpt_ids(&fx, b), [&w[..2], &before[..]].concat());
|
||||||
|
|
||||||
|
// all the waypoints of a file
|
||||||
|
let (mut fx, a, b) = build();
|
||||||
|
let w = wpt_ids(&fx, a);
|
||||||
|
let before = wpt_ids(&fx, b);
|
||||||
|
do_move(&mut fx, Selection::Waypoints { file_id: a }, at(b, 1)).unwrap();
|
||||||
|
assert!(wpt_ids(&fx, a).is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
wpt_ids(&fx, b),
|
||||||
|
[&before[..1], &w[..], &before[1..]].concat()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_move_files() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[], 0));
|
||||||
|
let b = add(&mut fx, file("b", &[], 0));
|
||||||
|
let c = add(&mut fx, file("c", &[], 0));
|
||||||
|
do_move(&mut fx, files(&[c]), MoveTarget::Files { index: 0 }).unwrap();
|
||||||
|
assert_eq!(fx.order.0, vec![c, a, b]);
|
||||||
|
do_move(&mut fx, files(&[c, a]), MoveTarget::Files { index: 1 }).unwrap();
|
||||||
|
assert_eq!(fx.order.0, vec![b, c, a]);
|
||||||
|
assert_eq!(fx.selection, files(&[c, a]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_what_cannot_be_moved() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[("t", 1)], 1));
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
let s = segment_ids(&fx, a, 0);
|
||||||
|
let w = wpt_ids(&fx, a);
|
||||||
|
let before = fx.files[&a].clone();
|
||||||
|
let selection = fx.selection.clone();
|
||||||
|
|
||||||
|
let invalid = [
|
||||||
|
// the wrong kind of list
|
||||||
|
(
|
||||||
|
files(&[a]),
|
||||||
|
MoveTarget::Tracks {
|
||||||
|
file_id: a,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
tracks(a, &t),
|
||||||
|
MoveTarget::Waypoints {
|
||||||
|
file_id: a,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
tracks(a, &t),
|
||||||
|
MoveTarget::Segments {
|
||||||
|
file_id: a,
|
||||||
|
trk_id: t[0],
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
segments(a, t[0], &s),
|
||||||
|
MoveTarget::Waypoints {
|
||||||
|
file_id: a,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
waypoints(a, &w),
|
||||||
|
MoveTarget::Tracks {
|
||||||
|
file_id: a,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(waypoints(a, &w), MoveTarget::Files { index: 0 }),
|
||||||
|
(
|
||||||
|
Selection::Waypoints { file_id: a },
|
||||||
|
MoveTarget::Files { index: 0 },
|
||||||
|
),
|
||||||
|
(Selection::Empty, MoveTarget::Files { index: 0 }),
|
||||||
|
// elements that do not exist
|
||||||
|
(
|
||||||
|
tracks(a, &[TrackId::default()]),
|
||||||
|
MoveTarget::Files { index: 0 },
|
||||||
|
),
|
||||||
|
(
|
||||||
|
waypoints(a, &[WaypointId::default()]),
|
||||||
|
MoveTarget::Waypoints {
|
||||||
|
file_id: a,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(files(&[FileId::default()]), MoveTarget::Files { index: 0 }),
|
||||||
|
// a place that does not exist
|
||||||
|
(
|
||||||
|
tracks(a, &t),
|
||||||
|
MoveTarget::Tracks {
|
||||||
|
file_id: FileId::default(),
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
segments(a, t[0], &s),
|
||||||
|
MoveTarget::Segments {
|
||||||
|
file_id: a,
|
||||||
|
trk_id: TrackId::default(),
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
waypoints(a, &w),
|
||||||
|
MoveTarget::Waypoints {
|
||||||
|
file_id: FileId::default(),
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (what, to) in invalid {
|
||||||
|
assert_eq!(
|
||||||
|
do_move(&mut fx, what.clone(), to),
|
||||||
|
Err(CommandError::NothingToDo),
|
||||||
|
"{what:?} to {to:?}"
|
||||||
|
);
|
||||||
|
// nothing changed
|
||||||
|
assert!(Rc::ptr_eq(&fx.files[&a], &before));
|
||||||
|
assert_eq!(fx.order.0, vec![a]);
|
||||||
|
assert_eq!(fx.selection, selection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
use crate::{Apply, CommandError, File, LngLat, State, Track, TrackSegment, Trackpoint, produce};
|
||||||
|
|
||||||
|
/// Creates an empty file and selects it. With a `trackpoint`, the file starts with a track and a
|
||||||
|
/// segment that hold it: it is how the routing tool starts a route on an empty map, in a single
|
||||||
|
/// undo step.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct New<'a> {
|
||||||
|
pub name: &'a str,
|
||||||
|
pub trackpoint: Option<NewTrackpoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct NewTrackpoint {
|
||||||
|
pub lng: f64,
|
||||||
|
pub lat: f64,
|
||||||
|
pub ele: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for New<'_> {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
produce(state, |_| {
|
||||||
|
let mut file = File::default();
|
||||||
|
file.info.name = self.name.to_owned();
|
||||||
|
if let Some(point) = self.trackpoint {
|
||||||
|
let mut segment = TrackSegment::default();
|
||||||
|
// the ends of a segment are anchors of the routing tool
|
||||||
|
segment.splice(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
vec![Trackpoint {
|
||||||
|
coordinates: LngLat {
|
||||||
|
lng: point.lng,
|
||||||
|
lat: point.lat,
|
||||||
|
},
|
||||||
|
ele: point.ele,
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
file.trk.push(Track {
|
||||||
|
trkseg: vec![segment],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vec![file]
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::engine::command::fixture::Fixture;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
assert!(
|
||||||
|
New {
|
||||||
|
name: "new",
|
||||||
|
trackpoint: None
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert_eq!(fx.files.len(), 1);
|
||||||
|
let file = fx.files.values().next().unwrap();
|
||||||
|
assert_eq!(file.info.name, "new");
|
||||||
|
assert!(file.trk.is_empty());
|
||||||
|
assert_eq!(fx.selected_files(), [file.id].into());
|
||||||
|
assert_eq!(fx.order.0, vec![file.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_with_a_trackpoint() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let point = NewTrackpoint {
|
||||||
|
lng: 1.0,
|
||||||
|
lat: 2.0,
|
||||||
|
ele: 3.0,
|
||||||
|
};
|
||||||
|
New {
|
||||||
|
name: "new",
|
||||||
|
trackpoint: Some(point),
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let file = fx.files.values().next().unwrap();
|
||||||
|
assert_eq!(file.trk.len(), 1);
|
||||||
|
assert_eq!(file.trk[0].trkseg.len(), 1);
|
||||||
|
let segment = &file.trk[0].trkseg[0];
|
||||||
|
assert_eq!(segment.len(), 1);
|
||||||
|
assert_eq!(segment[0].coordinates.lng, 1.0);
|
||||||
|
assert_eq!(segment[0].coordinates.lat, 2.0);
|
||||||
|
assert_eq!(segment[0].ele, 3.0);
|
||||||
|
// the only trackpoint is an anchor of the routing tool
|
||||||
|
assert_eq!(segment[0].anchor, Some(0));
|
||||||
|
assert_eq!(fx.selected_files(), [file.id].into());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
use std::{collections::HashSet, hash::Hash};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Apply, ClipboardContent, CommandError, Destination, FileId, Place, Selection, State, TrackId,
|
||||||
|
TrackSegmentId, WaypointId, transfer, waypoint_ids,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Pastes what was copied or cut (see [`crate::Clipboard`]).
|
||||||
|
///
|
||||||
|
/// Where it goes depends on the selection, see [`crate::Clipboard::can_paste`]:
|
||||||
|
/// - nothing selected: the files are copied, and tracks and segments become new files (named
|
||||||
|
/// like their track, or like the file they come from);
|
||||||
|
/// - a file: the tracks are added to it, segments become new tracks in it, and the waypoints are
|
||||||
|
/// added to its own;
|
||||||
|
/// - a track: the segments are added to it, tracks go after it in its file;
|
||||||
|
/// - a segment: the segments go after it in its track;
|
||||||
|
/// - the waypoints or a waypoint: the waypoints are added to their file, after the waypoint.
|
||||||
|
///
|
||||||
|
/// Copies are made, with new ids, and the pasted elements are selected. What was cut is moved
|
||||||
|
/// instead, with its ids, the first time: what is still where it was is removed.
|
||||||
|
///
|
||||||
|
/// What is pasted is always the elements as they were when they were copied or cut, even if they
|
||||||
|
/// were changed or deleted since. The clipboard is kept so that it can be pasted again: after
|
||||||
|
/// what was cut was moved it is not cut anymore, and the next pastes make copies.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Paste;
|
||||||
|
|
||||||
|
impl Apply for Paste {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let clipboard = state
|
||||||
|
.clipboard
|
||||||
|
.clone()
|
||||||
|
.filter(|clipboard| clipboard.can_paste(state.selection))
|
||||||
|
.ok_or(CommandError::NothingToDo)?;
|
||||||
|
let cut = clipboard.cut;
|
||||||
|
// always the elements as they were when they were copied or cut
|
||||||
|
let to = destination(state, &clipboard.content)?;
|
||||||
|
let next = transfer(state, &clipboard.content, cut, to)?;
|
||||||
|
|
||||||
|
if cut && let Some(clipboard) = state.clipboard.as_mut() {
|
||||||
|
// moved: the next pastes make copies
|
||||||
|
clipboard.cut = false;
|
||||||
|
}
|
||||||
|
*state.selection = next;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_ids_of(state: &State, file_id: FileId) -> Vec<TrackId> {
|
||||||
|
state
|
||||||
|
.files
|
||||||
|
.get(&file_id)
|
||||||
|
.map(|file| file.trk.iter().map(|trk| trk.id).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment_ids_of(state: &State, file_id: FileId, trk_id: TrackId) -> Vec<TrackSegmentId> {
|
||||||
|
state
|
||||||
|
.files
|
||||||
|
.get(&file_id)
|
||||||
|
.and_then(|file| file.trk.iter().find(|trk| trk.id == trk_id))
|
||||||
|
.map(|trk| trk.trkseg.iter().map(|seg| seg.id).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn waypoint_ids_of(state: &State, file_id: FileId) -> Vec<WaypointId> {
|
||||||
|
state
|
||||||
|
.files
|
||||||
|
.get(&file_id)
|
||||||
|
.map(|file| waypoint_ids(file).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the content goes, depending on what is selected.
|
||||||
|
fn destination(state: &State, content: &ClipboardContent) -> Result<Destination, CommandError> {
|
||||||
|
let nothing = CommandError::NothingToDo;
|
||||||
|
let last_file = |file_ids: &HashSet<FileId>| {
|
||||||
|
last_selected(state.order.0.iter().copied(), file_ids).ok_or(CommandError::NothingToDo)
|
||||||
|
};
|
||||||
|
Ok(match (content, &*state.selection) {
|
||||||
|
(ClipboardContent::Files(_), _) => Destination::Files(Place::End),
|
||||||
|
(ClipboardContent::Tracks(_) | ClipboardContent::Segments(_), Selection::Empty) => {
|
||||||
|
Destination::Files(Place::End)
|
||||||
|
}
|
||||||
|
(
|
||||||
|
ClipboardContent::Tracks(_) | ClipboardContent::Segments(_),
|
||||||
|
Selection::File { file_ids },
|
||||||
|
) => Destination::Tracks(last_file(file_ids)?, Place::End),
|
||||||
|
(ClipboardContent::Tracks(_), Selection::Track { file_id, trk_ids }) => {
|
||||||
|
let last =
|
||||||
|
last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?;
|
||||||
|
Destination::Tracks(*file_id, Place::After(last))
|
||||||
|
}
|
||||||
|
(ClipboardContent::Segments(_), Selection::Track { file_id, trk_ids }) => {
|
||||||
|
let last =
|
||||||
|
last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?;
|
||||||
|
Destination::Segments(*file_id, last, Place::End)
|
||||||
|
}
|
||||||
|
(
|
||||||
|
ClipboardContent::Segments(_),
|
||||||
|
Selection::TrackSegment {
|
||||||
|
file_id,
|
||||||
|
trk_id,
|
||||||
|
trkseg_ids,
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
let last = last_selected(
|
||||||
|
segment_ids_of(state, *file_id, *trk_id).into_iter(),
|
||||||
|
trkseg_ids,
|
||||||
|
)
|
||||||
|
.ok_or(nothing)?;
|
||||||
|
Destination::Segments(*file_id, *trk_id, Place::After(last))
|
||||||
|
}
|
||||||
|
(ClipboardContent::Waypoints(_), Selection::File { file_ids }) => {
|
||||||
|
Destination::Waypoints(last_file(file_ids)?, Place::End)
|
||||||
|
}
|
||||||
|
(ClipboardContent::Waypoints(_), Selection::Waypoints { file_id }) => {
|
||||||
|
Destination::Waypoints(*file_id, Place::End)
|
||||||
|
}
|
||||||
|
(ClipboardContent::Waypoints(_), Selection::Waypoint { file_id, wpt_ids }) => {
|
||||||
|
let last = last_selected(waypoint_ids_of(state, *file_id).into_iter(), wpt_ids)
|
||||||
|
.ok_or(nothing)?;
|
||||||
|
Destination::Waypoints(*file_id, Place::After(last))
|
||||||
|
}
|
||||||
|
_ => return Err(nothing),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last of the `selected` ids in the order of `all`: the element a paste goes after.
|
||||||
|
fn last_selected<T: Copy + Eq + Hash>(
|
||||||
|
all: impl Iterator<Item = T>,
|
||||||
|
selected: &HashSet<T>,
|
||||||
|
) -> Option<T> {
|
||||||
|
all.filter(|id| selected.contains(id)).last()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Clipboard, ClipboardIds, File, FileId, Track, TrackId, TrackInfo, TrackSegment,
|
||||||
|
TrackSegmentId, Waypoint, WaypointId, Waypoints, engine::command::fixture::Fixture,
|
||||||
|
new_file,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A file with the given tracks (name, number of segments) and number of waypoints.
|
||||||
|
fn file(name: &str, tracks: &[(Option<&str>, usize)], waypoints: usize) -> File {
|
||||||
|
let mut file = new_file(
|
||||||
|
name,
|
||||||
|
tracks
|
||||||
|
.iter()
|
||||||
|
.map(|(name, segments)| Track {
|
||||||
|
info: TrackInfo {
|
||||||
|
name: name.map(str::to_owned),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
trkseg: (0..*segments).map(|_| TrackSegment::default()).collect(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
if waypoints > 0 {
|
||||||
|
file.wpt = Waypoints::new([crate::WaypointChunk {
|
||||||
|
wpt: (0..waypoints).map(|_| Waypoint::default()).collect(),
|
||||||
|
..Default::default()
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
file
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(fx: &mut Fixture, file: File) -> FileId {
|
||||||
|
let id = file.id;
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
fx.order.0.push(id);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_ids(fx: &Fixture, id: FileId) -> Vec<TrackId> {
|
||||||
|
fx.files[&id].trk.iter().map(|trk| trk.id).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment_ids(fx: &Fixture, id: FileId, trk: usize) -> Vec<TrackSegmentId> {
|
||||||
|
fx.files[&id].trk[trk].trkseg.iter().map(|s| s.id).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wpt_ids(fx: &Fixture, id: FileId) -> Vec<WaypointId> {
|
||||||
|
waypoint_ids(&fx.files[&id]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn names(fx: &Fixture) -> Vec<String> {
|
||||||
|
fx.order
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|id| fx.files[id].info.name.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy(fx: &mut Fixture, selection: Selection, cut: bool) {
|
||||||
|
fx.clipboard = Clipboard::new(&selection, &fx.files, &fx.order.0, cut);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ids of what is in the clipboard, and whether it is cut.
|
||||||
|
fn clipboard_state(fx: &Fixture) -> Option<(ClipboardIds, bool)> {
|
||||||
|
fx.clipboard.as_ref().map(|c| (c.content.ids(), c.cut))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paste(fx: &mut Fixture) -> Result<(), CommandError> {
|
||||||
|
Paste.apply(&mut fx.state())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tracks(file_id: FileId, ids: &[TrackId]) -> Selection {
|
||||||
|
Selection::Track {
|
||||||
|
file_id,
|
||||||
|
trk_ids: ids.iter().copied().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segments(file_id: FileId, trk_id: TrackId, ids: &[TrackSegmentId]) -> Selection {
|
||||||
|
Selection::TrackSegment {
|
||||||
|
file_id,
|
||||||
|
trk_id,
|
||||||
|
trkseg_ids: ids.iter().copied().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn waypoints(file_id: FileId, ids: &[WaypointId]) -> Selection {
|
||||||
|
Selection::Waypoint {
|
||||||
|
file_id,
|
||||||
|
wpt_ids: ids.iter().copied().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn files(ids: &[FileId]) -> Selection {
|
||||||
|
Selection::File {
|
||||||
|
file_ids: ids.iter().copied().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_copied_tracks_into_a_file() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(
|
||||||
|
&mut fx,
|
||||||
|
file("a", &[(Some("t0"), 1), (Some("t1"), 2), (None, 1)], 0),
|
||||||
|
);
|
||||||
|
let b = add(&mut fx, file("b", &[(Some("b0"), 1)], 0));
|
||||||
|
let source = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, tracks(a, &[source[0], source[1]]), false);
|
||||||
|
fx.selection = files(&[b]);
|
||||||
|
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
|
||||||
|
// the source is untouched, copies with new ids are added at the end
|
||||||
|
assert_eq!(track_ids(&fx, a), source);
|
||||||
|
let in_b = track_ids(&fx, b);
|
||||||
|
assert_eq!(in_b.len(), 3);
|
||||||
|
assert!(!source.contains(&in_b[1]) && !source.contains(&in_b[2]));
|
||||||
|
let names: Vec<_> = fx.files[&b]
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.info.name.clone())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
names,
|
||||||
|
[Some("b0".into()), Some("t0".into()), Some("t1".into())]
|
||||||
|
);
|
||||||
|
// the segments are copies too
|
||||||
|
assert_eq!(fx.files[&b].trk[2].trkseg.len(), 2);
|
||||||
|
assert_ne!(segment_ids(&fx, b, 2), segment_ids(&fx, a, 1));
|
||||||
|
// the pasted tracks are selected, and the clipboard is still there
|
||||||
|
assert_eq!(fx.selection, tracks(b, &in_b[1..]));
|
||||||
|
assert_eq!(
|
||||||
|
clipboard_state(&fx),
|
||||||
|
Some((ClipboardIds::Tracks(vec![source[0], source[1]]), false))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_cut_tracks_moves_them() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
|
||||||
|
let b = add(&mut fx, file("b", &[], 0));
|
||||||
|
let source = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, tracks(a, &[source[0]]), true);
|
||||||
|
fx.selection = files(&[b]);
|
||||||
|
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(track_ids(&fx, a), vec![source[1]]);
|
||||||
|
assert_eq!(track_ids(&fx, b), vec![source[0]]);
|
||||||
|
assert_eq!(fx.selection, tracks(b, &[source[0]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_tracks_after_the_selected_track() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(
|
||||||
|
&mut fx,
|
||||||
|
file(
|
||||||
|
"a",
|
||||||
|
&[
|
||||||
|
(Some("0"), 1),
|
||||||
|
(Some("1"), 1),
|
||||||
|
(Some("2"), 1),
|
||||||
|
(Some("3"), 1),
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
// copy: the block goes after the selected track
|
||||||
|
copy(&mut fx, tracks(a, &[t[0], t[1]]), false);
|
||||||
|
fx.selection = tracks(a, &[t[2]]);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
let names: Vec<_> = fx.files[&a]
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.info.name.clone().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(names, ["0", "1", "2", "0", "1", "3"]);
|
||||||
|
|
||||||
|
// cut: moved after the selected track
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(
|
||||||
|
&mut fx,
|
||||||
|
file("a", &[(Some("0"), 1), (Some("1"), 1), (Some("2"), 1)], 0),
|
||||||
|
);
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, tracks(a, &[t[0]]), true);
|
||||||
|
fx.selection = tracks(a, &[t[2]]);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(track_ids(&fx, a), vec![t[1], t[2], t[0]]);
|
||||||
|
|
||||||
|
// the selected track is cut itself: the tracks go at the end
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(
|
||||||
|
&mut fx,
|
||||||
|
file("a", &[(Some("0"), 1), (Some("1"), 1), (Some("2"), 1)], 0),
|
||||||
|
);
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, tracks(a, &[t[0]]), true);
|
||||||
|
fx.selection = tracks(a, &[t[0]]);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(track_ids(&fx, a), vec![t[1], t[2], t[0]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_tracks_as_new_files() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("source", &[(Some("named"), 1), (None, 1)], 0));
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, tracks(a, &[t[0], t[1]]), false);
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
|
||||||
|
// named like the track, or like the file it comes from
|
||||||
|
assert_eq!(names(&fx), ["source", "named", "source"]);
|
||||||
|
assert_eq!(fx.files.len(), 3);
|
||||||
|
let new = &fx.order.0[1..];
|
||||||
|
assert!(
|
||||||
|
matches!(&fx.selection, Selection::File { file_ids } if file_ids.len() == 2 && new.iter().all(|id| file_ids.contains(id)))
|
||||||
|
);
|
||||||
|
assert_eq!(fx.files[&new[0]].trk.len(), 1);
|
||||||
|
// the source keeps its tracks
|
||||||
|
assert_eq!(track_ids(&fx, a), t);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_cut_tracks_as_new_files() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(
|
||||||
|
&mut fx,
|
||||||
|
file("source", &[(Some("one"), 1), (Some("two"), 1)], 0),
|
||||||
|
);
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, tracks(a, &[t[0]]), true);
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(track_ids(&fx, a), vec![t[1]]);
|
||||||
|
let new = fx.order.0[1];
|
||||||
|
assert_eq!(track_ids(&fx, new), vec![t[0]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_segments() {
|
||||||
|
let build = || {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[(Some("t0"), 3), (Some("t1"), 1)], 0));
|
||||||
|
(fx, a)
|
||||||
|
};
|
||||||
|
|
||||||
|
// at the end of the selected track
|
||||||
|
let (mut fx, a) = build();
|
||||||
|
let s = segment_ids(&fx, a, 0);
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, segments(a, t[0], &[s[0]]), false);
|
||||||
|
fx.selection = tracks(a, &[t[1]]);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(segment_ids(&fx, a, 0), s);
|
||||||
|
let in_t1 = segment_ids(&fx, a, 1);
|
||||||
|
assert_eq!(in_t1.len(), 2);
|
||||||
|
assert_ne!(in_t1[1], s[0]);
|
||||||
|
assert_eq!(fx.selection, segments(a, t[1], &in_t1[1..]));
|
||||||
|
|
||||||
|
// after the selected segment, moved
|
||||||
|
let (mut fx, a) = build();
|
||||||
|
let s = segment_ids(&fx, a, 0);
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, segments(a, t[0], &[s[0]]), true);
|
||||||
|
fx.selection = segments(a, t[0], &[s[1]]);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(segment_ids(&fx, a, 0), vec![s[1], s[0], s[2]]);
|
||||||
|
|
||||||
|
// each segment becomes a track of the selected file
|
||||||
|
let (mut fx, a) = build();
|
||||||
|
let s = segment_ids(&fx, a, 0);
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, segments(a, t[0], &[s[0], s[1]]), false);
|
||||||
|
fx.selection = files(&[a]);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
let tracks_now = track_ids(&fx, a);
|
||||||
|
assert_eq!(tracks_now.len(), 4);
|
||||||
|
assert!(
|
||||||
|
fx.files[&a].trk[2..]
|
||||||
|
.iter()
|
||||||
|
.all(|trk| trk.trkseg.len() == 1)
|
||||||
|
);
|
||||||
|
assert_eq!(fx.selection, tracks(a, &tracks_now[2..]));
|
||||||
|
|
||||||
|
// each segment becomes a file, named like its track
|
||||||
|
let (mut fx, a) = build();
|
||||||
|
let s = segment_ids(&fx, a, 0);
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, segments(a, t[0], &[s[0], s[1]]), false);
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(names(&fx), ["a", "t0", "t0"]);
|
||||||
|
assert!(
|
||||||
|
fx.order.0[1..]
|
||||||
|
.iter()
|
||||||
|
.all(|id| fx.files[id].trk[0].trkseg.len() == 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_waypoints() {
|
||||||
|
let build = || {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[], 4));
|
||||||
|
let b = add(&mut fx, file("b", &[], 2));
|
||||||
|
(fx, a, b)
|
||||||
|
};
|
||||||
|
|
||||||
|
// to the end of another file
|
||||||
|
let (mut fx, a, b) = build();
|
||||||
|
let w = wpt_ids(&fx, a);
|
||||||
|
let before = wpt_ids(&fx, b);
|
||||||
|
copy(&mut fx, waypoints(a, &[w[1], w[2]]), false);
|
||||||
|
fx.selection = files(&[b]);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(wpt_ids(&fx, a), w);
|
||||||
|
let now = wpt_ids(&fx, b);
|
||||||
|
assert_eq!(now.len(), 4);
|
||||||
|
assert_eq!(&now[..2], &before[..]);
|
||||||
|
assert!(!w.contains(&now[2]));
|
||||||
|
assert_eq!(fx.selection, waypoints(b, &now[2..]));
|
||||||
|
|
||||||
|
// after the selected waypoint
|
||||||
|
let (mut fx, a, b) = build();
|
||||||
|
let w = wpt_ids(&fx, a);
|
||||||
|
let before = wpt_ids(&fx, b);
|
||||||
|
copy(&mut fx, waypoints(a, &[w[0]]), true);
|
||||||
|
fx.selection = waypoints(b, &[before[0]]);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(wpt_ids(&fx, a), w[1..].to_vec());
|
||||||
|
assert_eq!(wpt_ids(&fx, b), vec![before[0], w[0], before[1]]);
|
||||||
|
|
||||||
|
// within the file, after a waypoint
|
||||||
|
let (mut fx, a, _) = build();
|
||||||
|
let w = wpt_ids(&fx, a);
|
||||||
|
copy(&mut fx, waypoints(a, &[w[0], w[1]]), true);
|
||||||
|
fx.selection = waypoints(a, &[w[2]]);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(wpt_ids(&fx, a), vec![w[2], w[0], w[1], w[3]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_all_the_waypoints_of_a_file() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[], 3));
|
||||||
|
let b = add(&mut fx, file("b", &[], 1));
|
||||||
|
let w = wpt_ids(&fx, a);
|
||||||
|
copy(&mut fx, Selection::Waypoints { file_id: a }, false);
|
||||||
|
fx.selection = Selection::Waypoints { file_id: b };
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(wpt_ids(&fx, a), w);
|
||||||
|
assert_eq!(wpt_ids(&fx, b).len(), 4);
|
||||||
|
|
||||||
|
// moved: the source has none left
|
||||||
|
copy(&mut fx, Selection::Waypoints { file_id: a }, true);
|
||||||
|
fx.selection = files(&[b]);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert!(wpt_ids(&fx, a).is_empty());
|
||||||
|
assert_eq!(&wpt_ids(&fx, b)[4..], &w[..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_files() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 1));
|
||||||
|
let b = add(&mut fx, file("b", &[], 0));
|
||||||
|
let c = add(&mut fx, file("c", &[], 0));
|
||||||
|
copy(&mut fx, files(&[b, a]), false);
|
||||||
|
fx.selection = files(&[c]);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
// copies, in the order of the files, at the end
|
||||||
|
assert_eq!(names(&fx), ["a", "b", "c", "a", "b"]);
|
||||||
|
assert_eq!(fx.files.len(), 5);
|
||||||
|
assert_eq!(fx.files[&fx.order.0[3]].trk.len(), 1);
|
||||||
|
assert_ne!(track_ids(&fx, fx.order.0[3]), track_ids(&fx, a));
|
||||||
|
assert!(matches!(&fx.selection, Selection::File { file_ids } if file_ids.len() == 2));
|
||||||
|
|
||||||
|
// cut: moved to the end, they keep their ids
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[], 0));
|
||||||
|
let b = add(&mut fx, file("b", &[], 0));
|
||||||
|
let c = add(&mut fx, file("c", &[], 0));
|
||||||
|
copy(&mut fx, files(&[a]), true);
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(fx.order.0, vec![b, c, a]);
|
||||||
|
assert_eq!(fx.selection, files(&[a]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nothing_to_paste() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 1));
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
let w = wpt_ids(&fx, a);
|
||||||
|
fx.selection = files(&[a]);
|
||||||
|
|
||||||
|
// an empty clipboard
|
||||||
|
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
|
||||||
|
|
||||||
|
// a target that does not accept it (tracks cannot go to waypoints), the clipboard stays
|
||||||
|
copy(&mut fx, tracks(a, &t), false);
|
||||||
|
fx.selection = waypoints(a, &w);
|
||||||
|
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
|
||||||
|
assert!(fx.clipboard.is_some());
|
||||||
|
assert_eq!(fx.selection, waypoints(a, &w));
|
||||||
|
|
||||||
|
// nothing to copy: what does not exist is not put in the clipboard
|
||||||
|
copy(&mut fx, tracks(a, &[TrackId::default()]), false);
|
||||||
|
fx.selection = files(&[a]);
|
||||||
|
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
|
||||||
|
copy(&mut fx, waypoints(a, &[WaypointId::default()]), false);
|
||||||
|
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
|
||||||
|
copy(&mut fx, files(&[FileId::default()]), false);
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
|
||||||
|
assert_eq!(fx.files.len(), 1);
|
||||||
|
assert_eq!(fx.order.0, vec![a]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_a_copy_several_times() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 1));
|
||||||
|
let b = add(&mut fx, file("b", &[], 0));
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, tracks(a, &[t[0]]), false);
|
||||||
|
fx.selection = files(&[b]);
|
||||||
|
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
|
||||||
|
// each paste made a copy with new ids, and the source is untouched
|
||||||
|
let in_b = track_ids(&fx, b);
|
||||||
|
assert_eq!(in_b.len(), 3);
|
||||||
|
assert_eq!(in_b.iter().collect::<HashSet<_>>().len(), 3);
|
||||||
|
assert!(in_b.iter().all(|id| !t.contains(id)));
|
||||||
|
assert_eq!(track_ids(&fx, a), t);
|
||||||
|
assert!(fx.clipboard.is_some());
|
||||||
|
|
||||||
|
// the selection is the last copy: the next paste goes after it
|
||||||
|
assert_eq!(fx.selection, tracks(b, &[in_b[2]]));
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(track_ids(&fx, b).len(), 4);
|
||||||
|
|
||||||
|
// the same with other elements
|
||||||
|
let w = wpt_ids(&fx, a);
|
||||||
|
copy(&mut fx, waypoints(a, &w), false);
|
||||||
|
fx.selection = files(&[b]);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(wpt_ids(&fx, b).len(), 2);
|
||||||
|
assert_eq!(wpt_ids(&fx, a), w);
|
||||||
|
|
||||||
|
copy(&mut fx, files(&[b]), false);
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(fx.files.len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_a_cut_moves_the_first_time_then_copies() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
|
||||||
|
let b = add(&mut fx, file("b", &[], 0));
|
||||||
|
let c = add(&mut fx, file("c", &[], 0));
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, tracks(a, &[t[0]]), true);
|
||||||
|
fx.selection = files(&[b]);
|
||||||
|
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
// moved with its id, and what is in the clipboard now is a copy of it
|
||||||
|
assert_eq!(track_ids(&fx, a), vec![t[1]]);
|
||||||
|
assert_eq!(track_ids(&fx, b), vec![t[0]]);
|
||||||
|
assert_eq!(
|
||||||
|
clipboard_state(&fx),
|
||||||
|
Some((ClipboardIds::Tracks(vec![t[0]]), false))
|
||||||
|
);
|
||||||
|
|
||||||
|
fx.selection = files(&[c]);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
// copies: the moved track stays where it was moved to
|
||||||
|
assert_eq!(track_ids(&fx, b), vec![t[0]]);
|
||||||
|
let in_c = track_ids(&fx, c);
|
||||||
|
assert_eq!(in_c.len(), 2);
|
||||||
|
assert!(!in_c.contains(&t[0]));
|
||||||
|
assert_eq!(track_ids(&fx, a), vec![t[1]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_cut_files_several_times() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[], 0));
|
||||||
|
let b = add(&mut fx, file("b", &[], 0));
|
||||||
|
copy(&mut fx, files(&[a]), true);
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(fx.order.0, vec![b, a]);
|
||||||
|
// the next pastes copy it
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(fx.files.len(), 3);
|
||||||
|
assert_eq!(fx.order.0[..2], [b, a]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renames the first track of the file, as an edit would.
|
||||||
|
fn rename_track(fx: &mut Fixture, id: FileId, name: &str) {
|
||||||
|
let file = std::rc::Rc::make_mut(fx.files.get_mut(&id).unwrap());
|
||||||
|
file.trk[0].info.name = Some(name.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_the_elements_as_they_were_when_copied() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[(Some("original"), 1)], 0));
|
||||||
|
let b = add(&mut fx, file("b", &[], 0));
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, tracks(a, &[t[0]]), false);
|
||||||
|
rename_track(&mut fx, a, "edited");
|
||||||
|
fx.selection = files(&[b]);
|
||||||
|
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
let pasted = &fx.files[&b].trk[0];
|
||||||
|
assert_eq!(pasted.info.name.as_deref(), Some("original"));
|
||||||
|
// the source keeps what was done to it
|
||||||
|
assert_eq!(fx.files[&a].trk[0].info.name.as_deref(), Some("edited"));
|
||||||
|
|
||||||
|
// and each time
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert!(
|
||||||
|
fx.files[&b]
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.all(|trk| trk.info.name.as_deref() == Some("original"))
|
||||||
|
);
|
||||||
|
assert_eq!(fx.files[&b].trk.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_a_cut_as_it_was_when_cut() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(
|
||||||
|
&mut fx,
|
||||||
|
file("a", &[(Some("original"), 1), (Some("other"), 1)], 0),
|
||||||
|
);
|
||||||
|
let b = add(&mut fx, file("b", &[], 0));
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, tracks(a, &[t[0]]), true);
|
||||||
|
rename_track(&mut fx, a, "edited");
|
||||||
|
fx.selection = files(&[b]);
|
||||||
|
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
// what is in the clipboard is moved, the edited version is not what is pasted
|
||||||
|
assert_eq!(track_ids(&fx, a), vec![t[1]]);
|
||||||
|
assert_eq!(track_ids(&fx, b), vec![t[0]]);
|
||||||
|
assert_eq!(fx.files[&b].trk[0].info.name.as_deref(), Some("original"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_after_the_elements_were_deleted() {
|
||||||
|
let delete_file = |fx: &mut Fixture, id: FileId| {
|
||||||
|
fx.files.remove(&id);
|
||||||
|
fx.order.0.retain(|other| *other != id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// copied tracks, waypoints, segments and files are still there to be pasted
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
|
||||||
|
let b = add(&mut fx, file("b", &[], 0));
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
|
||||||
|
copy(&mut fx, tracks(a, &t), false);
|
||||||
|
fx.selection = files(&[b]);
|
||||||
|
delete_file(&mut fx, a);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(fx.files[&b].trk.len(), 1);
|
||||||
|
assert_eq!(fx.files[&b].trk[0].trkseg.len(), 2);
|
||||||
|
// the copy of a track comes from a file that is gone: named like the track
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(names(&fx), ["b", "t"]);
|
||||||
|
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
|
||||||
|
let b = add(&mut fx, file("b", &[], 0));
|
||||||
|
let (t, s) = (track_ids(&fx, a), segment_ids(&fx, a, 0));
|
||||||
|
copy(&mut fx, segments(a, t[0], &s), false);
|
||||||
|
fx.selection = files(&[b]);
|
||||||
|
delete_file(&mut fx, a);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(fx.files[&b].trk.len(), 2);
|
||||||
|
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
|
||||||
|
let b = add(&mut fx, file("b", &[], 0));
|
||||||
|
let w = wpt_ids(&fx, a);
|
||||||
|
copy(&mut fx, waypoints(a, &w), false);
|
||||||
|
fx.selection = files(&[b]);
|
||||||
|
delete_file(&mut fx, a);
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(wpt_ids(&fx, b).len(), 2);
|
||||||
|
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 0));
|
||||||
|
copy(&mut fx, files(&[a]), false);
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
delete_file(&mut fx, a);
|
||||||
|
assert!(fx.files.is_empty());
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
assert_eq!(fx.files.len(), 2);
|
||||||
|
assert_eq!(names(&fx), ["a", "a"]);
|
||||||
|
let _ = (s, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paste_a_cut_after_the_elements_were_deleted() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
|
||||||
|
let b = add(&mut fx, file("b", &[], 0));
|
||||||
|
let t = track_ids(&fx, a);
|
||||||
|
copy(&mut fx, tracks(a, &[t[0]]), true);
|
||||||
|
// deleted before it is pasted
|
||||||
|
std::rc::Rc::make_mut(fx.files.get_mut(&a).unwrap())
|
||||||
|
.trk
|
||||||
|
.retain(|trk| trk.id != t[0]);
|
||||||
|
fx.selection = files(&[b]);
|
||||||
|
|
||||||
|
paste(&mut fx).unwrap();
|
||||||
|
// nothing to remove, the track is where it was pasted, with its id
|
||||||
|
assert_eq!(track_ids(&fx, a), vec![t[1]]);
|
||||||
|
assert_eq!(track_ids(&fx, b), vec![t[0]]);
|
||||||
|
assert_eq!(
|
||||||
|
clipboard_state(&fx),
|
||||||
|
Some((ClipboardIds::Tracks(vec![t[0]]), false))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{Apply, CommandError, FileId, Load, State};
|
||||||
|
|
||||||
|
/// A file that comes from the outside (the file system), under the identity it has here.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ExternalFile<'a> {
|
||||||
|
pub id: FileId,
|
||||||
|
pub data: &'a [u8],
|
||||||
|
/// Name of the file when the data has none (see [`Load`]).
|
||||||
|
pub name: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brings changes of the files from the outside in, as a single command (one undo step): the files
|
||||||
|
/// that are given replace the ones of the same id (which stay where they are in the order, and keep
|
||||||
|
/// their id), or are added at the end if there are none; the ones to `remove` are deleted.
|
||||||
|
///
|
||||||
|
/// Unlike [`crate::LoadFiles`], the selection is left alone (what it holds of a file that was
|
||||||
|
/// replaced or deleted is dropped by the engine afterwards, as with any edit).
|
||||||
|
///
|
||||||
|
/// The files that cannot be read are skipped, and the files that are here stay as they are. It is an
|
||||||
|
/// error when nothing could be done.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct SyncFiles<'a> {
|
||||||
|
pub files: Vec<ExternalFile<'a>>,
|
||||||
|
pub remove: Vec<FileId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for SyncFiles<'_> {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let mut changed = false;
|
||||||
|
let mut first_error = None;
|
||||||
|
for external in self.files {
|
||||||
|
let load = Load {
|
||||||
|
data: external.data,
|
||||||
|
name: external.name,
|
||||||
|
};
|
||||||
|
match load.parse(state.categories) {
|
||||||
|
Ok(mut file) => {
|
||||||
|
file.id = external.id;
|
||||||
|
if state.files.insert(external.id, Rc::new(file)).is_none() {
|
||||||
|
state.order.0.push(external.id);
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
first_error.get_or_insert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in self.remove {
|
||||||
|
if state.files.remove(&id).is_some() {
|
||||||
|
state.order.0.retain(|other| *other != id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(first_error.unwrap_or(CommandError::NothingToDo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::engine::command::fixture::Fixture;
|
||||||
|
use crate::{New, Selection};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const GPX: &[u8] = br#"<gpx version="1.1"><metadata><name>from disk</name></metadata></gpx>"#;
|
||||||
|
|
||||||
|
fn new_file(fx: &mut Fixture, name: &str) -> FileId {
|
||||||
|
New {
|
||||||
|
name,
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
*fx.selected_files().iter().next().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn external(id: FileId) -> ExternalFile<'static> {
|
||||||
|
ExternalFile {
|
||||||
|
id,
|
||||||
|
data: GPX,
|
||||||
|
name: "name",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sync_replaces_a_file_in_place() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let first = new_file(&mut fx, "first");
|
||||||
|
let second = new_file(&mut fx, "second");
|
||||||
|
SyncFiles {
|
||||||
|
files: vec![external(first)],
|
||||||
|
remove: vec![],
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fx.order.0, [first, second]);
|
||||||
|
assert_eq!(fx.files[&first].info.name, "from disk");
|
||||||
|
assert_eq!(fx.files[&first].id, first);
|
||||||
|
// the selection is not changed
|
||||||
|
assert_eq!(fx.selected_files(), [second].into());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sync_adds_and_removes_files() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let first = new_file(&mut fx, "first");
|
||||||
|
let added = FileId::default();
|
||||||
|
SyncFiles {
|
||||||
|
files: vec![external(added)],
|
||||||
|
remove: vec![first],
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fx.order.0, [added]);
|
||||||
|
assert!(!fx.files.contains_key(&first));
|
||||||
|
// what was selected is gone
|
||||||
|
assert!(matches!(fx.selection, Selection::File { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sync_with_nothing_to_do_or_invalid_data_is_an_error() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
assert_eq!(
|
||||||
|
SyncFiles::default().apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
let invalid = SyncFiles {
|
||||||
|
files: vec![ExternalFile {
|
||||||
|
id: FileId::default(),
|
||||||
|
data: b"<gpx><trk></gpx>",
|
||||||
|
name: "x",
|
||||||
|
}],
|
||||||
|
remove: vec![],
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
invalid.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::InvalidData(_))
|
||||||
|
));
|
||||||
|
assert!(fx.files.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
//! Copying or moving elements to a place of the file tree: what paste and move are made of.
|
||||||
|
//! The elements come as the content of a clipboard, and a destination says where they go among
|
||||||
|
//! the elements already there.
|
||||||
|
|
||||||
|
use std::{collections::HashSet, rc::Rc};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ClipboardContent, ClipboardSegment, ClipboardTrack, CommandError, File, FileId, Selection,
|
||||||
|
State, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, WaypointId, copy_file,
|
||||||
|
copy_segment, copy_track, copy_waypoint,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Where elements are put, among the others of the same list.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Place<T> {
|
||||||
|
/// After all of them.
|
||||||
|
End,
|
||||||
|
/// Right after the one with this id, or at the end if it is not there (anymore).
|
||||||
|
After(T),
|
||||||
|
/// At this position (at the end if it is past it).
|
||||||
|
Index(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Copy + PartialEq> Place<T> {
|
||||||
|
/// The position in the list of `ids`.
|
||||||
|
pub fn position(&self, ids: &[T]) -> usize {
|
||||||
|
match self {
|
||||||
|
Place::End => ids.len(),
|
||||||
|
Place::After(id) => ids
|
||||||
|
.iter()
|
||||||
|
.position(|other| other == id)
|
||||||
|
.map_or(ids.len(), |i| i + 1),
|
||||||
|
Place::Index(index) => (*index).min(ids.len()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where elements go. What can go where:
|
||||||
|
/// - files: among the files;
|
||||||
|
/// - tracks: among the files (each track becomes a file, named like the track, or like the file
|
||||||
|
/// it comes from), or among the tracks of a file;
|
||||||
|
/// - segments: among the files (each segment becomes a file with a track, named like its track,
|
||||||
|
/// or like its file), among the tracks of a file (each segment becomes a track), or among the
|
||||||
|
/// segments of a track;
|
||||||
|
/// - waypoints: among the waypoints of a file.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Destination {
|
||||||
|
Files(Place<FileId>),
|
||||||
|
Tracks(FileId, Place<TrackId>),
|
||||||
|
Segments(FileId, TrackId, Place<TrackSegmentId>),
|
||||||
|
Waypoints(FileId, Place<WaypointId>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn waypoint_ids(file: &File) -> impl Iterator<Item = WaypointId> + '_ {
|
||||||
|
file.wpt.iter().map(|wpt| wpt.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_mut<'a>(state: &'a mut State, id: FileId) -> Result<&'a mut File, CommandError> {
|
||||||
|
state
|
||||||
|
.files
|
||||||
|
.get_mut(&id)
|
||||||
|
.map(Rc::make_mut)
|
||||||
|
.ok_or(CommandError::NothingToDo)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_file(name: &str, trk: Vec<Track>) -> File {
|
||||||
|
let mut file = File {
|
||||||
|
trk,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
file.info.name = name.to_owned();
|
||||||
|
file
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds the files to the state, at `place` in the order of the files.
|
||||||
|
fn add_files(state: &mut State, files: Vec<Rc<File>>, place: Place<FileId>) -> Selection {
|
||||||
|
let at = place.position(&state.order.0);
|
||||||
|
let ids: Vec<FileId> = files.iter().map(|file| file.id).collect();
|
||||||
|
for file in files {
|
||||||
|
state.files.insert(file.id, file);
|
||||||
|
}
|
||||||
|
state.order.0.splice(at..at, ids.iter().copied());
|
||||||
|
Selection::File {
|
||||||
|
file_ids: ids.into_iter().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the elements of the content from the files, wherever they are.
|
||||||
|
pub fn remove_elements(state: &mut State, content: &ClipboardContent) {
|
||||||
|
match content {
|
||||||
|
ClipboardContent::Files(files) => {
|
||||||
|
let ids: HashSet<FileId> = files.iter().map(|file| file.id).collect();
|
||||||
|
state.files.retain(|id, _| !ids.contains(id));
|
||||||
|
state.order.0.retain(|id| !ids.contains(id));
|
||||||
|
}
|
||||||
|
ClipboardContent::Tracks(tracks) => {
|
||||||
|
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.track.id).collect();
|
||||||
|
for file in state.files.values_mut() {
|
||||||
|
if file.trk.iter().any(|trk| ids.contains(&trk.id)) {
|
||||||
|
Rc::make_mut(file).trk.retain(|trk| !ids.contains(&trk.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClipboardContent::Segments(segments) => {
|
||||||
|
let ids: HashSet<TrackSegmentId> = segments.iter().map(|seg| seg.segment.id).collect();
|
||||||
|
for file in state.files.values_mut() {
|
||||||
|
let contains = |trk: &Track| trk.trkseg.iter().any(|seg| ids.contains(&seg.id));
|
||||||
|
if file.trk.iter().any(contains) {
|
||||||
|
for trk in Rc::make_mut(file).trk.iter_mut() {
|
||||||
|
trk.trkseg.retain(|seg| !ids.contains(&seg.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClipboardContent::Waypoints(waypoints) => {
|
||||||
|
let ids: HashSet<WaypointId> = waypoints.iter().map(|wpt| wpt.id).collect();
|
||||||
|
for file in state.files.values_mut() {
|
||||||
|
if waypoint_ids(file).any(|id| ids.contains(&id)) {
|
||||||
|
Rc::make_mut(file).wpt.edit(
|
||||||
|
|wpt| ids.contains(&wpt.id),
|
||||||
|
|wpts| {
|
||||||
|
wpts.retain(|wpt| !ids.contains(&wpt.id));
|
||||||
|
true
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts the elements of the content at the destination: copies of them, or the elements
|
||||||
|
/// themselves, with their ids, when `cut` (they are removed from where they are first). Returns
|
||||||
|
/// the selection of what was put.
|
||||||
|
pub fn transfer(
|
||||||
|
state: &mut State,
|
||||||
|
content: &ClipboardContent,
|
||||||
|
cut: bool,
|
||||||
|
to: Destination,
|
||||||
|
) -> Result<Selection, CommandError> {
|
||||||
|
// the destination has to be there, before anything is changed
|
||||||
|
let exists = match to {
|
||||||
|
Destination::Files(_) => true,
|
||||||
|
Destination::Tracks(file_id, _) => state.files.contains_key(&file_id),
|
||||||
|
Destination::Segments(file_id, trk_id, _) => state
|
||||||
|
.files
|
||||||
|
.get(&file_id)
|
||||||
|
.is_some_and(|file| file.trk.iter().any(|trk| trk.id == trk_id)),
|
||||||
|
Destination::Waypoints(file_id, _) => state.files.contains_key(&file_id),
|
||||||
|
};
|
||||||
|
let valid = matches!(
|
||||||
|
(content, to),
|
||||||
|
(ClipboardContent::Files(_), Destination::Files(_))
|
||||||
|
| (
|
||||||
|
ClipboardContent::Tracks(_),
|
||||||
|
Destination::Files(_) | Destination::Tracks(..)
|
||||||
|
)
|
||||||
|
| (
|
||||||
|
ClipboardContent::Segments(_),
|
||||||
|
Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..),
|
||||||
|
)
|
||||||
|
| (ClipboardContent::Waypoints(_), Destination::Waypoints(..))
|
||||||
|
);
|
||||||
|
let empty = match content {
|
||||||
|
ClipboardContent::Files(files) => files.is_empty(),
|
||||||
|
ClipboardContent::Tracks(tracks) => tracks.is_empty(),
|
||||||
|
ClipboardContent::Segments(segments) => segments.is_empty(),
|
||||||
|
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
|
||||||
|
};
|
||||||
|
if !exists || !valid || empty {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cut {
|
||||||
|
remove_elements(state, content);
|
||||||
|
}
|
||||||
|
let copy = !cut;
|
||||||
|
match (content, to) {
|
||||||
|
(ClipboardContent::Files(files), Destination::Files(place)) => {
|
||||||
|
let files = files
|
||||||
|
.iter()
|
||||||
|
.map(|file| {
|
||||||
|
if copy {
|
||||||
|
Rc::new(copy_file(file))
|
||||||
|
} else {
|
||||||
|
file.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(add_files(state, files, place))
|
||||||
|
}
|
||||||
|
(ClipboardContent::Tracks(tracks), to) => transfer_tracks(state, tracks, copy, to),
|
||||||
|
(ClipboardContent::Segments(segments), to) => transfer_segments(state, segments, copy, to),
|
||||||
|
(ClipboardContent::Waypoints(waypoints), Destination::Waypoints(file_id, place)) => {
|
||||||
|
transfer_waypoints(state, waypoints, copy, file_id, place)
|
||||||
|
}
|
||||||
|
_ => Err(CommandError::NothingToDo),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transfer_tracks(
|
||||||
|
state: &mut State,
|
||||||
|
tracks: &[ClipboardTrack],
|
||||||
|
copy: bool,
|
||||||
|
to: Destination,
|
||||||
|
) -> Result<Selection, CommandError> {
|
||||||
|
let tracks: Vec<(Track, &str)> = tracks
|
||||||
|
.iter()
|
||||||
|
.map(|clip| {
|
||||||
|
let track = if copy {
|
||||||
|
copy_track(&clip.track)
|
||||||
|
} else {
|
||||||
|
clip.track.clone()
|
||||||
|
};
|
||||||
|
(track, clip.file_name.as_str())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
match to {
|
||||||
|
Destination::Files(place) => {
|
||||||
|
let files = tracks
|
||||||
|
.into_iter()
|
||||||
|
.map(|(track, file_name)| {
|
||||||
|
let name = track
|
||||||
|
.info
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| file_name.to_owned());
|
||||||
|
Rc::new(new_file(&name, vec![track]))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(add_files(state, files, place))
|
||||||
|
}
|
||||||
|
Destination::Tracks(file_id, place) => {
|
||||||
|
let tracks: Vec<Track> = tracks.into_iter().map(|(track, _)| track).collect();
|
||||||
|
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.id).collect();
|
||||||
|
let file = file_mut(state, file_id)?;
|
||||||
|
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
|
||||||
|
let at = place.position(&existing);
|
||||||
|
file.trk.splice(at..at, tracks);
|
||||||
|
Ok(Selection::Track {
|
||||||
|
file_id,
|
||||||
|
trk_ids: ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Err(CommandError::NothingToDo),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transfer_segments(
|
||||||
|
state: &mut State,
|
||||||
|
segments: &[ClipboardSegment],
|
||||||
|
copy: bool,
|
||||||
|
to: Destination,
|
||||||
|
) -> Result<Selection, CommandError> {
|
||||||
|
let segments: Vec<(TrackSegment, &str)> = segments
|
||||||
|
.iter()
|
||||||
|
.map(|clip| {
|
||||||
|
let segment = if copy {
|
||||||
|
copy_segment(&clip.segment)
|
||||||
|
} else {
|
||||||
|
clip.segment.clone()
|
||||||
|
};
|
||||||
|
(segment, clip.name.as_str())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// a segment on its own needs a track
|
||||||
|
let own_track = |segment: TrackSegment| Track {
|
||||||
|
trkseg: vec![segment],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
match to {
|
||||||
|
Destination::Files(place) => {
|
||||||
|
let files = segments
|
||||||
|
.into_iter()
|
||||||
|
.map(|(segment, name)| Rc::new(new_file(name, vec![own_track(segment)])))
|
||||||
|
.collect();
|
||||||
|
Ok(add_files(state, files, place))
|
||||||
|
}
|
||||||
|
Destination::Tracks(file_id, place) => {
|
||||||
|
let tracks: Vec<Track> = segments
|
||||||
|
.into_iter()
|
||||||
|
.map(|(segment, _)| own_track(segment))
|
||||||
|
.collect();
|
||||||
|
let ids = tracks.iter().map(|trk| trk.id).collect();
|
||||||
|
let file = file_mut(state, file_id)?;
|
||||||
|
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
|
||||||
|
let at = place.position(&existing);
|
||||||
|
file.trk.splice(at..at, tracks);
|
||||||
|
Ok(Selection::Track {
|
||||||
|
file_id,
|
||||||
|
trk_ids: ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Destination::Segments(file_id, trk_id, place) => {
|
||||||
|
let segments: Vec<TrackSegment> =
|
||||||
|
segments.into_iter().map(|(segment, _)| segment).collect();
|
||||||
|
let ids: HashSet<TrackSegmentId> = segments.iter().map(|seg| seg.id).collect();
|
||||||
|
let trk = file_mut(state, file_id)?
|
||||||
|
.trk
|
||||||
|
.iter_mut()
|
||||||
|
.find(|trk| trk.id == trk_id)
|
||||||
|
.ok_or(CommandError::NothingToDo)?;
|
||||||
|
let existing: Vec<TrackSegmentId> = trk.trkseg.iter().map(|seg| seg.id).collect();
|
||||||
|
let at = place.position(&existing);
|
||||||
|
trk.trkseg.splice(at..at, segments);
|
||||||
|
Ok(Selection::TrackSegment {
|
||||||
|
file_id,
|
||||||
|
trk_id,
|
||||||
|
trkseg_ids: ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Destination::Waypoints(..) => Err(CommandError::NothingToDo),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transfer_waypoints(
|
||||||
|
state: &mut State,
|
||||||
|
waypoints: &[Waypoint],
|
||||||
|
copy: bool,
|
||||||
|
file_id: FileId,
|
||||||
|
place: Place<WaypointId>,
|
||||||
|
) -> Result<Selection, CommandError> {
|
||||||
|
let waypoints: Vec<Waypoint> = waypoints
|
||||||
|
.iter()
|
||||||
|
.map(|wpt| {
|
||||||
|
if copy {
|
||||||
|
copy_waypoint(wpt)
|
||||||
|
} else {
|
||||||
|
wpt.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let ids = waypoints.iter().map(|wpt| wpt.id).collect();
|
||||||
|
let file = file_mut(state, file_id)?;
|
||||||
|
let existing: Vec<WaypointId> = waypoint_ids(file).collect();
|
||||||
|
file.wpt.insert_at(place.position(&existing), waypoints);
|
||||||
|
Ok(Selection::Waypoint {
|
||||||
|
file_id,
|
||||||
|
wpt_ids: ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_place_position() {
|
||||||
|
let ids = [1, 2, 3];
|
||||||
|
assert_eq!(Place::End.position(&ids), 3);
|
||||||
|
assert_eq!(Place::After(1).position(&ids), 1);
|
||||||
|
assert_eq!(Place::After(3).position(&ids), 3);
|
||||||
|
// not there: at the end
|
||||||
|
assert_eq!(Place::After(9).position(&ids), 3);
|
||||||
|
assert_eq!(Place::Index(0).position(&ids), 0);
|
||||||
|
assert_eq!(Place::Index(2).position(&ids), 2);
|
||||||
|
assert_eq!(Place::Index(10).position(&ids), 3);
|
||||||
|
assert_eq!(Place::<i32>::End.position(&[]), 0);
|
||||||
|
assert_eq!(Place::<i32>::Index(4).position(&[]), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//! Shared helpers for the tests of the commands.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::{Clipboard, FileId, FileOrder, Files, Selection, State, TrackpointCategories};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Fixture {
|
||||||
|
pub files: Files,
|
||||||
|
pub selection: Selection,
|
||||||
|
pub order: FileOrder,
|
||||||
|
pub clipboard: Option<Clipboard>,
|
||||||
|
pub categories: TrackpointCategories,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
pub fn state(&mut self) -> State<'_> {
|
||||||
|
State {
|
||||||
|
categories: &mut self.categories,
|
||||||
|
files: &mut self.files,
|
||||||
|
selection: &mut self.selection,
|
||||||
|
order: &mut self.order,
|
||||||
|
clipboard: &mut self.clipboard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ids of the selected files, empty unless the selection is a file selection.
|
||||||
|
pub fn selected_files(&self) -> HashSet<FileId> {
|
||||||
|
match &self.selection {
|
||||||
|
Selection::File { file_ids } => file_ids.clone(),
|
||||||
|
_ => HashSet::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
mod apply;
|
||||||
|
mod edit;
|
||||||
|
mod file;
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod fixture;
|
||||||
|
mod pattern;
|
||||||
|
mod tools;
|
||||||
|
|
||||||
|
pub use apply::*;
|
||||||
|
pub use edit::*;
|
||||||
|
pub use file::*;
|
||||||
|
pub use pattern::*;
|
||||||
|
pub use tools::*;
|
||||||
|
|
||||||
|
use crate::State;
|
||||||
|
|
||||||
|
/// A user action that edits the files. Each variant wraps the command's own struct, whose
|
||||||
|
/// effect is implemented in its own file (see `Apply`).
|
||||||
|
///
|
||||||
|
/// Bulk data (coordinates, file bytes) is borrowed straight from the wasm-bindgen argument
|
||||||
|
/// buffers, so a command is built without copying; the engine only copies what it keeps.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Command<'a> {
|
||||||
|
New(New<'a>),
|
||||||
|
Load(Load<'a>),
|
||||||
|
LoadFiles(LoadFiles<'a>),
|
||||||
|
SyncFiles(SyncFiles<'a>),
|
||||||
|
Duplicate(Duplicate),
|
||||||
|
Delete(Delete),
|
||||||
|
Paste(Paste),
|
||||||
|
Move(Move),
|
||||||
|
DeleteAll(DeleteAll),
|
||||||
|
DeleteWaypoint(DeleteWaypoint),
|
||||||
|
Metadata(Metadata<'a>),
|
||||||
|
Style(Style<'a>),
|
||||||
|
NewTrack(NewTrack),
|
||||||
|
NewTrackSegment(NewTrackSegment),
|
||||||
|
Reverse(Reverse),
|
||||||
|
RoundTrip(RoundTrip),
|
||||||
|
Route(Route<'a>),
|
||||||
|
NewWaypoint(NewWaypoint<'a>),
|
||||||
|
EditWaypoint(EditWaypoint<'a>),
|
||||||
|
MoveWaypoint(MoveWaypoint),
|
||||||
|
Crop(Crop),
|
||||||
|
Reduce(Reduce),
|
||||||
|
Split(Split),
|
||||||
|
Time(Time),
|
||||||
|
Merge(Merge),
|
||||||
|
Extract(Extract),
|
||||||
|
InsertAnchor(InsertAnchor),
|
||||||
|
Elevation(Elevation<'a>),
|
||||||
|
Clean(Clean),
|
||||||
|
ChangeLoopStart(ChangeLoopStart),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for Command<'_> {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
match self {
|
||||||
|
Command::New(c) => c.apply(state),
|
||||||
|
Command::Load(c) => c.apply(state),
|
||||||
|
Command::LoadFiles(c) => c.apply(state),
|
||||||
|
Command::SyncFiles(c) => c.apply(state),
|
||||||
|
Command::Duplicate(c) => c.apply(state),
|
||||||
|
Command::Delete(c) => c.apply(state),
|
||||||
|
Command::Paste(c) => c.apply(state),
|
||||||
|
Command::Move(c) => c.apply(state),
|
||||||
|
Command::DeleteAll(c) => c.apply(state),
|
||||||
|
Command::DeleteWaypoint(c) => c.apply(state),
|
||||||
|
Command::Metadata(c) => c.apply(state),
|
||||||
|
Command::Style(c) => c.apply(state),
|
||||||
|
Command::NewTrack(c) => c.apply(state),
|
||||||
|
Command::NewTrackSegment(c) => c.apply(state),
|
||||||
|
Command::Reverse(c) => c.apply(state),
|
||||||
|
Command::RoundTrip(c) => c.apply(state),
|
||||||
|
Command::Route(c) => c.apply(state),
|
||||||
|
Command::NewWaypoint(c) => c.apply(state),
|
||||||
|
Command::EditWaypoint(c) => c.apply(state),
|
||||||
|
Command::MoveWaypoint(c) => c.apply(state),
|
||||||
|
Command::Crop(c) => c.apply(state),
|
||||||
|
Command::Reduce(c) => c.apply(state),
|
||||||
|
Command::Split(c) => c.apply(state),
|
||||||
|
Command::Time(c) => c.apply(state),
|
||||||
|
Command::Merge(c) => c.apply(state),
|
||||||
|
Command::Extract(c) => c.apply(state),
|
||||||
|
Command::InsertAnchor(c) => c.apply(state),
|
||||||
|
Command::Elevation(c) => c.apply(state),
|
||||||
|
Command::Clean(c) => c.apply(state),
|
||||||
|
Command::ChangeLoopStart(c) => c.apply(state),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
use crate::{File, Track, TrackSegment, Waypoint};
|
||||||
|
|
||||||
|
// The trackpoints of a segment are shared chunks, so copying elements is cheap.
|
||||||
|
|
||||||
|
/// A copy of the segment, with a new id.
|
||||||
|
pub fn copy_segment(segment: &TrackSegment) -> TrackSegment {
|
||||||
|
let mut copy = segment.clone();
|
||||||
|
copy.id = Default::default();
|
||||||
|
copy
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A copy of the track and of its segments, with new ids.
|
||||||
|
pub fn copy_track(track: &Track) -> Track {
|
||||||
|
Track {
|
||||||
|
id: Default::default(),
|
||||||
|
trkseg: track.trkseg.iter().map(copy_segment).collect(),
|
||||||
|
..track.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A copy of the file and of its tracks and segments, with new ids.
|
||||||
|
pub fn copy_file(file: &File) -> File {
|
||||||
|
File {
|
||||||
|
id: Default::default(),
|
||||||
|
trk: file.trk.iter().map(copy_track).collect(),
|
||||||
|
..file.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A copy of the waypoint, with a new id.
|
||||||
|
pub fn copy_waypoint(waypoint: &Waypoint) -> Waypoint {
|
||||||
|
let mut copy = waypoint.clone();
|
||||||
|
copy.id = Default::default();
|
||||||
|
copy
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
mod copy;
|
||||||
|
mod produce;
|
||||||
|
mod update_segments;
|
||||||
|
mod update_selected;
|
||||||
|
mod update_waypoint;
|
||||||
|
|
||||||
|
pub use copy::*;
|
||||||
|
pub use produce::*;
|
||||||
|
pub use update_segments::*;
|
||||||
|
pub use update_selected::*;
|
||||||
|
pub use update_waypoint::*;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{File, Selection, State};
|
||||||
|
|
||||||
|
/// Pure producer: adds the files returned by `f` to the state and selects the first one.
|
||||||
|
pub fn produce<F>(state: &mut State, f: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&State) -> Vec<File>,
|
||||||
|
{
|
||||||
|
*state.selection = Selection::Empty;
|
||||||
|
for file in f(state) {
|
||||||
|
let file_id = file.id;
|
||||||
|
state.files.insert(file_id, Rc::new(file));
|
||||||
|
state.order.0.push(file_id);
|
||||||
|
if matches!(*state.selection, Selection::Empty) {
|
||||||
|
*state.selection = Selection::File {
|
||||||
|
file_ids: [file_id].into(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::engine::command::fixture::Fixture;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn named(name: &str) -> File {
|
||||||
|
let mut file = File::default();
|
||||||
|
file.info.name = name.to_string();
|
||||||
|
file
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_produce_adds_orders_and_selects_first() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let (a, b) = (named("a"), named("b"));
|
||||||
|
let (id_a, id_b) = (a.id, b.id);
|
||||||
|
produce(&mut fx.state(), |_| vec![a, b]);
|
||||||
|
|
||||||
|
assert_eq!(fx.files.len(), 2);
|
||||||
|
assert_eq!(fx.order.0, vec![id_a, id_b]);
|
||||||
|
assert_eq!(fx.selected_files(), HashSet::from([id_a]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_produce_replaces_selection_and_keeps_order() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = named("a");
|
||||||
|
let id_a = a.id;
|
||||||
|
produce(&mut fx.state(), |_| vec![a]);
|
||||||
|
let b = named("b");
|
||||||
|
let id_b = b.id;
|
||||||
|
produce(&mut fx.state(), |_| vec![b]);
|
||||||
|
|
||||||
|
assert_eq!(fx.order.0, vec![id_a, id_b]);
|
||||||
|
assert_eq!(fx.selected_files(), HashSet::from([id_b]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_produce_nothing_clears_selection() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
produce(&mut fx.state(), |_| vec![named("a")]);
|
||||||
|
produce(&mut fx.state(), |_| vec![]);
|
||||||
|
assert!(matches!(fx.selection, Selection::Empty));
|
||||||
|
assert_eq!(fx.files.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_produce_sees_current_state() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
produce(&mut fx.state(), |_| vec![named("a")]);
|
||||||
|
produce(&mut fx.state(), |state| {
|
||||||
|
assert_eq!(state.files.len(), 1);
|
||||||
|
vec![]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{File, FileId, SegmentLocation, State, TrackSegment};
|
||||||
|
|
||||||
|
/// Edits the segments at `locations`, calling `f` with the position of each location in the slice
|
||||||
|
/// and its segment, in that order.
|
||||||
|
///
|
||||||
|
/// Each file concerned is copied once (cheap, trackpoints are shared chunks) and stored back as a
|
||||||
|
/// new snapshot. The segments take care of their own revision (see [`TrackSegment`]).
|
||||||
|
pub fn update_segments(
|
||||||
|
state: &mut State,
|
||||||
|
locations: &[SegmentLocation],
|
||||||
|
mut f: impl FnMut(usize, &mut TrackSegment),
|
||||||
|
) {
|
||||||
|
let mut files: HashMap<FileId, File> = HashMap::new();
|
||||||
|
for (i, location) in locations.iter().enumerate() {
|
||||||
|
let file = files
|
||||||
|
.entry(location.file_id)
|
||||||
|
.or_insert_with(|| (*state.files[&location.file_id]).clone());
|
||||||
|
f(i, &mut file.trk[location.trk].trkseg[location.seg]);
|
||||||
|
}
|
||||||
|
for (file_id, file) in files {
|
||||||
|
state.files.insert(file_id, Rc::new(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::engine::command::fixture::Fixture;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixture() -> (Fixture, FileId) {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
|
||||||
|
let file = Rc::new(crate::parse(&data, &mut Default::default()).unwrap());
|
||||||
|
let id = file.id;
|
||||||
|
fx.files.insert(id, file);
|
||||||
|
(fx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_only_the_given_segments_are_edited_in_order() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
let locations = [
|
||||||
|
SegmentLocation {
|
||||||
|
file_id: id,
|
||||||
|
trk: 1,
|
||||||
|
seg: 0,
|
||||||
|
},
|
||||||
|
SegmentLocation {
|
||||||
|
file_id: id,
|
||||||
|
trk: 0,
|
||||||
|
seg: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let mut calls = vec![];
|
||||||
|
update_segments(&mut fx.state(), &locations, |i, segment| {
|
||||||
|
calls.push(i);
|
||||||
|
segment.update(0, |trkpt| trkpt.ele = 1234.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(calls, vec![0, 1]);
|
||||||
|
let after = &fx.files[&id];
|
||||||
|
assert!(!Rc::ptr_eq(after, &before));
|
||||||
|
for (trk, seg) in [(1, 0), (0, 0)] {
|
||||||
|
assert_eq!(after.trk[trk].trkseg[seg][0].ele, 1234.0);
|
||||||
|
assert_ne!(
|
||||||
|
after.trk[trk].trkseg[seg].rev_id,
|
||||||
|
before.trk[trk].trkseg[seg].rev_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// the other segments are the same ones
|
||||||
|
assert_eq!(
|
||||||
|
after.trk[0].trkseg[1].rev_id,
|
||||||
|
before.trk[0].trkseg[1].rev_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_location_leaves_the_files_alone() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
update_segments(&mut fx.state(), &[], |_, _| panic!("nothing to edit"));
|
||||||
|
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{File, FileId, Files, Selection, State, Track, TrackSegment, Waypoint};
|
||||||
|
|
||||||
|
/// What an [`Editor`] hook did to the element it was given.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Edit {
|
||||||
|
Unchanged,
|
||||||
|
Changed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Edit {
|
||||||
|
fn merge(self, other: Edit) -> Edit {
|
||||||
|
if self == Edit::Changed || other == Edit::Changed {
|
||||||
|
Edit::Changed
|
||||||
|
} else {
|
||||||
|
Edit::Unchanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Independent edit of each selected element, without needing the rest of the state.
|
||||||
|
///
|
||||||
|
/// A selected file, track or segment is passed to the hook of its own level. By default a
|
||||||
|
/// file is forwarded to its tracks and a track to its segments, so an editor only overrides
|
||||||
|
/// the level(s) it cares about (metadata: file and track, style: track, reverse: segment).
|
||||||
|
/// Waypoints are only edited when selected, never through their file.
|
||||||
|
pub trait Editor {
|
||||||
|
fn file(&mut self, file: &mut File) -> Edit {
|
||||||
|
edit_each(&mut file.trk, |trk| self.track(trk))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track(&mut self, track: &mut Track) -> Edit {
|
||||||
|
edit_each(&mut track.trkseg, |seg| edit_segment(self, seg))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment(&mut self, _segment: &mut TrackSegment) -> Edit {
|
||||||
|
Edit::Unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
fn waypoint(&mut self, _waypoint: &mut Waypoint) -> Edit {
|
||||||
|
Edit::Unchanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls the segment hook and gives the segment a new revision id if it changed, so that
|
||||||
|
/// derived data (statistics) is recomputed.
|
||||||
|
fn edit_segment<E: Editor + ?Sized>(editor: &mut E, segment: &mut TrackSegment) -> Edit {
|
||||||
|
let edit = editor.segment(segment);
|
||||||
|
if edit == Edit::Changed {
|
||||||
|
segment.rev_id = Default::default();
|
||||||
|
}
|
||||||
|
edit
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_each<T>(items: &mut [T], f: impl FnMut(&mut T) -> Edit) -> Edit {
|
||||||
|
edit_where(items, |_| true, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_where<T>(
|
||||||
|
items: &mut [T],
|
||||||
|
filter: impl Fn(&T) -> bool,
|
||||||
|
mut f: impl FnMut(&mut T) -> Edit,
|
||||||
|
) -> Edit {
|
||||||
|
items
|
||||||
|
.iter_mut()
|
||||||
|
.filter(|item| filter(item))
|
||||||
|
.fold(Edit::Unchanged, |acc, item| acc.merge(f(item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edits a copy of the file (cheap, track points are shared chunks) and stores it back as a
|
||||||
|
/// new snapshot only if something changed.
|
||||||
|
fn update_file(files: &mut Files, id: FileId, f: impl FnOnce(&mut File) -> Edit) {
|
||||||
|
let Some(current) = files.get(&id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut file = (**current).clone();
|
||||||
|
if f(&mut file) == Edit::Changed {
|
||||||
|
files.insert(id, Rc::new(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_waypoints<E: Editor + ?Sized>(
|
||||||
|
file: &mut File,
|
||||||
|
filter: impl Fn(&Waypoint) -> bool,
|
||||||
|
editor: &mut E,
|
||||||
|
) -> Edit {
|
||||||
|
let changed = file.wpt.edit(&filter, |wpts| {
|
||||||
|
edit_where(wpts, &filter, |wpt| editor.waypoint(wpt)) == Edit::Changed
|
||||||
|
});
|
||||||
|
if changed {
|
||||||
|
Edit::Changed
|
||||||
|
} else {
|
||||||
|
Edit::Unchanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the editor to every selected file, track or segment.
|
||||||
|
pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) {
|
||||||
|
match &*state.selection {
|
||||||
|
Selection::File { file_ids } => {
|
||||||
|
for id in file_ids {
|
||||||
|
update_file(state.files, *id, |file| editor.file(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Selection::Track { file_id, trk_ids } => {
|
||||||
|
update_file(state.files, *file_id, |file| {
|
||||||
|
edit_where(
|
||||||
|
&mut file.trk,
|
||||||
|
|trk| trk_ids.contains(&trk.id),
|
||||||
|
|trk| editor.track(trk),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Selection::TrackSegment {
|
||||||
|
file_id,
|
||||||
|
trk_id,
|
||||||
|
trkseg_ids,
|
||||||
|
} => {
|
||||||
|
update_file(state.files, *file_id, |file| {
|
||||||
|
edit_where(
|
||||||
|
&mut file.trk,
|
||||||
|
|trk| trk.id == *trk_id,
|
||||||
|
|trk| {
|
||||||
|
edit_where(
|
||||||
|
&mut trk.trkseg,
|
||||||
|
|seg| trkseg_ids.contains(&seg.id),
|
||||||
|
|seg| edit_segment(editor, seg),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Selection::Waypoints { file_id } => {
|
||||||
|
update_file(state.files, *file_id, |file| {
|
||||||
|
edit_waypoints(file, |_| true, editor)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Selection::Waypoint { file_id, wpt_ids } => {
|
||||||
|
update_file(state.files, *file_id, |file| {
|
||||||
|
edit_waypoints(file, |wpt| wpt_ids.contains(&wpt.id), editor)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Selection::Empty => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::engine::command::fixture::Fixture;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct Rename;
|
||||||
|
|
||||||
|
impl Editor for Rename {
|
||||||
|
fn file(&mut self, file: &mut File) -> Edit {
|
||||||
|
file.info.name.push('!');
|
||||||
|
Edit::Changed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track(&mut self, track: &mut Track) -> Edit {
|
||||||
|
track.info.name = Some("track".to_string());
|
||||||
|
Edit::Changed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment(&mut self, _: &mut TrackSegment) -> Edit {
|
||||||
|
Edit::Changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Nothing;
|
||||||
|
impl Editor for Nothing {}
|
||||||
|
|
||||||
|
fn fixture_with_tracks() -> (Fixture, FileId) {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
|
||||||
|
let file = Rc::new(crate::parse(&data, &mut Default::default()).unwrap());
|
||||||
|
let id = file.id;
|
||||||
|
fx.files.insert(id, file);
|
||||||
|
(fx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_only_selected_files_are_updated() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let (a, b, c) = (File::default(), File::default(), File::default());
|
||||||
|
let ids = [a.id, b.id, c.id];
|
||||||
|
for file in [a, b, c] {
|
||||||
|
fx.files.insert(file.id, Rc::new(file));
|
||||||
|
}
|
||||||
|
let untouched = fx.files[&ids[2]].clone();
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([ids[0], ids[1]]),
|
||||||
|
};
|
||||||
|
|
||||||
|
update_selected(&mut fx.state(), &mut Rename);
|
||||||
|
|
||||||
|
assert_eq!(fx.files[&ids[0]].info.name, "!");
|
||||||
|
assert_eq!(fx.files[&ids[1]].info.name, "!");
|
||||||
|
assert!(Rc::ptr_eq(&fx.files[&ids[2]], &untouched));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unchanged_file_keeps_its_snapshot() {
|
||||||
|
let (mut fx, id) = fixture_with_tracks();
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
};
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
update_selected(&mut fx.state(), &mut Nothing);
|
||||||
|
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_track_selection_uses_track_hook() {
|
||||||
|
let (mut fx, id) = fixture_with_tracks();
|
||||||
|
let trk_id = fx.files[&id].trk[0].id;
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: HashSet::from([trk_id]),
|
||||||
|
};
|
||||||
|
update_selected(&mut fx.state(), &mut Rename);
|
||||||
|
|
||||||
|
let file = &fx.files[&id];
|
||||||
|
assert_eq!(file.info.name, "with_tracks_and_segments");
|
||||||
|
assert_eq!(file.trk[0].info.name.as_deref(), Some("track"));
|
||||||
|
assert_ne!(file.trk[1].info.name.as_deref(), Some("track"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_segment_selection_bumps_revision_id() {
|
||||||
|
let (mut fx, id) = fixture_with_tracks();
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
let trk = &before.trk[0];
|
||||||
|
fx.selection = Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: trk.id,
|
||||||
|
trkseg_ids: HashSet::from([trk.trkseg[0].id]),
|
||||||
|
};
|
||||||
|
update_selected(&mut fx.state(), &mut Rename);
|
||||||
|
|
||||||
|
let after = &fx.files[&id];
|
||||||
|
assert_ne!(after.trk[0].trkseg[0].rev_id, trk.trkseg[0].rev_id);
|
||||||
|
assert_eq!(after.trk[0].trkseg[1].rev_id, trk.trkseg[1].rev_id);
|
||||||
|
assert_eq!(
|
||||||
|
after.trk[1].trkseg[0].rev_id,
|
||||||
|
before.trk[1].trkseg[0].rev_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_selection_cascades_to_segments() {
|
||||||
|
struct SegmentsOnly;
|
||||||
|
impl Editor for SegmentsOnly {
|
||||||
|
fn segment(&mut self, _: &mut TrackSegment) -> Edit {
|
||||||
|
Edit::Changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (mut fx, id) = fixture_with_tracks();
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
};
|
||||||
|
update_selected(&mut fx.state(), &mut SegmentsOnly);
|
||||||
|
for (b, a) in before.trk.iter().zip(fx.files[&id].trk.iter()) {
|
||||||
|
for (b, a) in b.trkseg.iter().zip(a.trkseg.iter()) {
|
||||||
|
assert_ne!(b.rev_id, a.rev_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_waypoint_selection_edits_only_selected_waypoints() {
|
||||||
|
struct Name;
|
||||||
|
impl Editor for Name {
|
||||||
|
fn waypoint(&mut self, wpt: &mut Waypoint) -> Edit {
|
||||||
|
wpt.name = Some("x".into());
|
||||||
|
Edit::Changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (mut fx, id) = fixture_with_tracks();
|
||||||
|
let wpts: Vec<_> = (0..3).map(|_| Waypoint::default()).collect();
|
||||||
|
let ids: Vec<_> = wpts.iter().map(|w| w.id).collect();
|
||||||
|
let mut file = (*fx.files[&id]).clone();
|
||||||
|
file.wpt = crate::Waypoints::new([crate::WaypointChunk {
|
||||||
|
wpt: wpts,
|
||||||
|
..Default::default()
|
||||||
|
}]);
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
let chunk_id = fx.files[&id].wpt.chunks()[0].id;
|
||||||
|
|
||||||
|
// A file selection never touches waypoints.
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
};
|
||||||
|
update_selected(&mut fx.state(), &mut Name);
|
||||||
|
assert!(fx.files[&id].wpt.iter().all(|w| w.name.is_none()));
|
||||||
|
|
||||||
|
fx.selection = Selection::Waypoint {
|
||||||
|
file_id: id,
|
||||||
|
wpt_ids: HashSet::from([ids[1]]),
|
||||||
|
};
|
||||||
|
let rev = fx.files[&id].wpt.rev_id;
|
||||||
|
update_selected(&mut fx.state(), &mut Name);
|
||||||
|
assert_ne!(fx.files[&id].wpt.rev_id, rev);
|
||||||
|
assert_ne!(fx.files[&id].wpt.chunks()[0].id, chunk_id);
|
||||||
|
let named: Vec<_> = fx.files[&id].wpt.iter().map(|w| w.name.is_some()).collect();
|
||||||
|
assert_eq!(named, [false, true, false]);
|
||||||
|
|
||||||
|
fx.selection = Selection::Waypoints { file_id: id };
|
||||||
|
update_selected(&mut fx.state(), &mut Name);
|
||||||
|
assert!(fx.files[&id].wpt.iter().all(|w| w.name.is_some()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_missing_files_and_other_selections_are_ignored() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let file = File::default();
|
||||||
|
let id = file.id;
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
|
||||||
|
for selection in [
|
||||||
|
Selection::File {
|
||||||
|
file_ids: HashSet::from([FileId::default()]),
|
||||||
|
},
|
||||||
|
Selection::Empty,
|
||||||
|
] {
|
||||||
|
fx.selection = selection;
|
||||||
|
update_selected(&mut fx.state(), &mut Rename);
|
||||||
|
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{CommandError, FileId, State, Waypoint, WaypointId};
|
||||||
|
|
||||||
|
/// Changes one waypoint of a file with `f`, whatever is selected. Nothing to do if the file or
|
||||||
|
/// the waypoint does not exist.
|
||||||
|
pub fn update_waypoint(
|
||||||
|
state: &mut State,
|
||||||
|
file_id: FileId,
|
||||||
|
waypoint_id: WaypointId,
|
||||||
|
f: impl Fn(&mut Waypoint),
|
||||||
|
) -> Result<(), CommandError> {
|
||||||
|
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||||
|
let mut file = (**file).clone();
|
||||||
|
let changed = file.wpt.edit(
|
||||||
|
|wpt| wpt.id == waypoint_id,
|
||||||
|
|wpts| {
|
||||||
|
wpts.iter_mut()
|
||||||
|
.filter(|wpt| wpt.id == waypoint_id)
|
||||||
|
.for_each(&f);
|
||||||
|
true
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if !changed {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
state.files.insert(file_id, Rc::new(file));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{Apply, CommandError, State, Trackpoint, replace_trackpoints};
|
||||||
|
|
||||||
|
use super::route::find_target;
|
||||||
|
|
||||||
|
/// Makes the trackpoint `index` of the selection (see `RoutingBuffer`) the start of its segment,
|
||||||
|
/// which is a loop: the trackpoints before it, and itself, are copied after the last trackpoint
|
||||||
|
/// of the segment, and the original ones are removed. The trackpoint is both the first and the
|
||||||
|
/// last one, which closes the loop on it.
|
||||||
|
///
|
||||||
|
/// The copies keep their durations, and start one second after the previous last trackpoint if
|
||||||
|
/// the segment has timestamps (see [`replace_trackpoints`]).
|
||||||
|
///
|
||||||
|
/// It is how the routing tool starts a loop somewhere else. The first and last trackpoints are
|
||||||
|
/// anchors shown at every zoom level, as any end of a segment.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ChangeLoopStart {
|
||||||
|
pub index: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for ChangeLoopStart {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let index = self.index as usize;
|
||||||
|
let target = find_target(state, index, index)?.ok_or(CommandError::NothingToDo)?;
|
||||||
|
let mut file = (*state.files[&target.location.file_id]).clone();
|
||||||
|
let segment = &mut file.trk[target.location.trk].trkseg[target.location.seg];
|
||||||
|
let (start, len) = (target.start, segment.len());
|
||||||
|
if start == 0 || start >= len {
|
||||||
|
// it already is the start, or it is not a trackpoint
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the trackpoint itself ends the loop as well
|
||||||
|
let moved: Vec<Trackpoint> = segment.iter().take(start + 1).cloned().collect();
|
||||||
|
// the copies are shifted after the last trackpoint, which is all that matters of the rest
|
||||||
|
let mut tail = vec![segment[len - 1].clone()];
|
||||||
|
replace_trackpoints(&mut tail, 1, 1, moved, None, None, false);
|
||||||
|
tail.remove(0);
|
||||||
|
|
||||||
|
// only the chunks at the ends of the segment change: the others stay shared
|
||||||
|
segment.splice(len, len, tail);
|
||||||
|
segment.splice(0, start, vec![]);
|
||||||
|
segment.rev_id = Default::default();
|
||||||
|
state.files.insert(target.location.file_id, Rc::new(file));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::{FileId, Load, Selection, TrackSegment, engine::command::fixture::Fixture};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn loaded(path: &str) -> (Fixture, FileId) {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let data = std::fs::read(path).unwrap();
|
||||||
|
Load {
|
||||||
|
data: &data,
|
||||||
|
name: "file",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let id = fx.order.0[0];
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
};
|
||||||
|
(fx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_segment(fx: &Fixture, id: FileId) -> TrackSegment {
|
||||||
|
fx.files[&id].trk[0].trkseg[0].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_points_before_move_to_the_end_and_the_loop_closes_on_the_start() {
|
||||||
|
let (mut fx, id) = loaded("data/simple.gpx");
|
||||||
|
let before = first_segment(&fx, id);
|
||||||
|
let n = before.len();
|
||||||
|
assert!(n > 10);
|
||||||
|
|
||||||
|
ChangeLoopStart { index: 4 }.apply(&mut fx.state()).unwrap();
|
||||||
|
let after = first_segment(&fx, id);
|
||||||
|
// the trackpoint is repeated at the end
|
||||||
|
assert_eq!(after.len(), n + 1);
|
||||||
|
assert_ne!(after.rev_id, before.rev_id);
|
||||||
|
let lng = |s: &TrackSegment, i: usize| s[i].coordinates.lng;
|
||||||
|
for i in 0..=n {
|
||||||
|
assert_eq!(lng(&after, i), lng(&before, (i + 4) % n), "{i}");
|
||||||
|
}
|
||||||
|
assert_eq!(lng(&after, n), lng(&after, 0));
|
||||||
|
// the start and the end of the new segment are anchors, whatever they were
|
||||||
|
assert_eq!(after[0].anchor, Some(0));
|
||||||
|
assert_eq!(after[n].anchor, Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_moved_part_is_shifted_after_the_last_point() {
|
||||||
|
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||||
|
let before = first_segment(&fx, id);
|
||||||
|
let n = before.len();
|
||||||
|
ChangeLoopStart { index: 30 }
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let after = first_segment(&fx, id);
|
||||||
|
assert_eq!(after.len(), n + 1);
|
||||||
|
let times: Vec<_> = after.iter().map(|p| p.time.unwrap()).collect();
|
||||||
|
assert!(times.windows(2).all(|w| w[0] < w[1]), "{times:?}");
|
||||||
|
// the part that was after the index is untouched
|
||||||
|
for i in 0..n - 30 {
|
||||||
|
assert_eq!(after[i].time, before[30 + i].time);
|
||||||
|
}
|
||||||
|
// the copies follow the last point by a second, and keep their durations
|
||||||
|
let last = before[n - 1].time.unwrap();
|
||||||
|
assert_eq!(after[n - 30].time, Some(last + 1000));
|
||||||
|
for i in n - 30..n {
|
||||||
|
let original = i - (n - 30);
|
||||||
|
assert_eq!(
|
||||||
|
after[i + 1].time.unwrap() - after[i].time.unwrap(),
|
||||||
|
before[original + 1].time.unwrap() - before[original].time.unwrap(),
|
||||||
|
"{i}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nothing_to_do_on_the_start_or_out_of_the_trackpoints() {
|
||||||
|
let (mut fx, id) = loaded("data/simple.gpx");
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
assert_eq!(
|
||||||
|
ChangeLoopStart { index: 0 }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
let total = first_segment(&fx, id).len() as u32;
|
||||||
|
assert!(matches!(
|
||||||
|
ChangeLoopStart { index: total + 5 }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::InvalidData(_))
|
||||||
|
));
|
||||||
|
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
use std::{collections::HashSet, rc::Rc};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Apply, CommandError, Edit, Editor, File, FileId, LngLat, LngLatBounds, Selection, State,
|
||||||
|
TrackSegment, TrackSegmentId, Trackpoint, Waypoint, delete_waypoints, update_selected,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum CleanType {
|
||||||
|
Inside,
|
||||||
|
Outside,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the trackpoints and/or waypoints of the selection lying inside (or outside) a
|
||||||
|
/// rectangle. A selected file cleans its tracks and its waypoints, a selected track or segment
|
||||||
|
/// only trackpoints, selected waypoints only waypoints.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Clean {
|
||||||
|
pub bounds: LngLatBounds,
|
||||||
|
pub type_: CleanType,
|
||||||
|
pub trkpt: bool,
|
||||||
|
pub wpt: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clean {
|
||||||
|
fn contains(&self, c: LngLat) -> bool {
|
||||||
|
(self.bounds.sw.lng..=self.bounds.ne.lng).contains(&c.lng)
|
||||||
|
&& (self.bounds.sw.lat..=self.bounds.ne.lat).contains(&c.lat)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn removes(&self, c: LngLat) -> bool {
|
||||||
|
self.contains(c) == (self.type_ == CleanType::Inside)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for Clean {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
match &*state.selection {
|
||||||
|
Selection::Waypoint { file_id, .. } | Selection::Waypoints { file_id } => {
|
||||||
|
let file_id = *file_id;
|
||||||
|
if !self.wpt {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
let wpt_ids = match &*state.selection {
|
||||||
|
Selection::Waypoint { wpt_ids, .. } => Some(wpt_ids.clone()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let selected =
|
||||||
|
|wpt: &Waypoint| wpt_ids.as_ref().is_none_or(|ids| ids.contains(&wpt.id));
|
||||||
|
*state.selection = delete_waypoints(state.files, file_id, |wpt| {
|
||||||
|
selected(wpt) && self.removes(wpt.coordinates)
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Selection::Empty => Err(CommandError::NothingToDo),
|
||||||
|
_ => {
|
||||||
|
let mut cleaner = Cleaner {
|
||||||
|
clean: &self,
|
||||||
|
changed: false,
|
||||||
|
emptied: HashSet::new(),
|
||||||
|
wpt_files: HashSet::new(),
|
||||||
|
};
|
||||||
|
update_selected(state, &mut cleaner);
|
||||||
|
if cleaner.changed {
|
||||||
|
prune(state, &cleaner.emptied, &cleaner.wpt_files);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Cleaner<'a> {
|
||||||
|
clean: &'a Clean,
|
||||||
|
changed: bool,
|
||||||
|
/// Segments left without trackpoints by the clean.
|
||||||
|
emptied: HashSet<TrackSegmentId>,
|
||||||
|
/// Files that lost waypoints.
|
||||||
|
wpt_files: HashSet<FileId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Editor for Cleaner<'_> {
|
||||||
|
fn file(&mut self, file: &mut File) -> Edit {
|
||||||
|
let mut edit = Edit::Unchanged;
|
||||||
|
for trk in &mut file.trk {
|
||||||
|
if self.track(trk) == Edit::Changed {
|
||||||
|
edit = Edit::Changed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.clean.wpt
|
||||||
|
&& file.wpt.edit(
|
||||||
|
|wpt| self.clean.removes(wpt.coordinates),
|
||||||
|
|wpts| {
|
||||||
|
wpts.retain(|wpt| !self.clean.removes(wpt.coordinates));
|
||||||
|
true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
{
|
||||||
|
edit = Edit::Changed;
|
||||||
|
self.wpt_files.insert(file.id);
|
||||||
|
}
|
||||||
|
self.changed |= edit == Edit::Changed;
|
||||||
|
edit
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment(&mut self, segment: &mut TrackSegment) -> Edit {
|
||||||
|
if !self.clean.trkpt || !segment.iter().any(|pt| self.clean.removes(pt.coordinates)) {
|
||||||
|
return Edit::Unchanged;
|
||||||
|
}
|
||||||
|
let kept: Vec<Trackpoint> = segment
|
||||||
|
.iter()
|
||||||
|
.filter(|pt| !self.clean.removes(pt.coordinates))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
if kept.is_empty() {
|
||||||
|
self.emptied.insert(segment.id);
|
||||||
|
}
|
||||||
|
segment.splice(0, segment.len(), kept);
|
||||||
|
self.changed = true;
|
||||||
|
Edit::Changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes what the clean left empty: the emptied segments, the tracks that lost all their
|
||||||
|
/// segments and the files that lost something and have nothing left. The selection falls back
|
||||||
|
/// to the closest element still existing.
|
||||||
|
fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) {
|
||||||
|
let ids: Vec<FileId> = state
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.filter(|(id, file)| {
|
||||||
|
wpt_files.contains(id)
|
||||||
|
|| file
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.any(|trk| trk.trkseg.iter().any(|seg| emptied.contains(&seg.id)))
|
||||||
|
})
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect();
|
||||||
|
for id in ids {
|
||||||
|
let mut file = (*state.files[&id]).clone();
|
||||||
|
let lost_something = !emptied.is_empty() || wpt_files.contains(&id);
|
||||||
|
file.trk.retain_mut(|trk| {
|
||||||
|
let len = trk.trkseg.len();
|
||||||
|
trk.trkseg.retain(|seg| !emptied.contains(&seg.id));
|
||||||
|
trk.trkseg.len() == len || !trk.trkseg.is_empty()
|
||||||
|
});
|
||||||
|
if lost_something && file.trk.is_empty() && file.wpt.is_empty() {
|
||||||
|
state.files.remove(&id);
|
||||||
|
state.order.0.retain(|f| *f != id);
|
||||||
|
} else {
|
||||||
|
state.files.insert(id, Rc::new(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*state.selection = fixed_selection(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixed_selection(state: &State) -> Selection {
|
||||||
|
let files = &*state.files;
|
||||||
|
match &*state.selection {
|
||||||
|
Selection::File { file_ids } => {
|
||||||
|
let file_ids: HashSet<_> = file_ids
|
||||||
|
.iter()
|
||||||
|
.filter(|id| files.contains_key(id))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
if file_ids.is_empty() {
|
||||||
|
Selection::Empty
|
||||||
|
} else {
|
||||||
|
Selection::File { file_ids }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Selection::Track { file_id, trk_ids } => {
|
||||||
|
let Some(file) = files.get(file_id) else {
|
||||||
|
return Selection::Empty;
|
||||||
|
};
|
||||||
|
let trk_ids: HashSet<_> = file
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.map(|trk| trk.id)
|
||||||
|
.filter(|id| trk_ids.contains(id))
|
||||||
|
.collect();
|
||||||
|
if trk_ids.is_empty() {
|
||||||
|
Selection::File {
|
||||||
|
file_ids: [*file_id].into(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Selection::Track {
|
||||||
|
file_id: *file_id,
|
||||||
|
trk_ids,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Selection::TrackSegment {
|
||||||
|
file_id,
|
||||||
|
trk_id,
|
||||||
|
trkseg_ids,
|
||||||
|
} => {
|
||||||
|
let Some(file) = files.get(file_id) else {
|
||||||
|
return Selection::Empty;
|
||||||
|
};
|
||||||
|
let Some(trk) = file.trk.iter().find(|trk| trk.id == *trk_id) else {
|
||||||
|
return Selection::File {
|
||||||
|
file_ids: [*file_id].into(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let trkseg_ids: HashSet<_> = trk
|
||||||
|
.trkseg
|
||||||
|
.iter()
|
||||||
|
.map(|seg| seg.id)
|
||||||
|
.filter(|id| trkseg_ids.contains(id))
|
||||||
|
.collect();
|
||||||
|
if trkseg_ids.is_empty() {
|
||||||
|
Selection::Track {
|
||||||
|
file_id: *file_id,
|
||||||
|
trk_ids: [*trk_id].into(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Selection::TrackSegment {
|
||||||
|
file_id: *file_id,
|
||||||
|
trk_id: *trk_id,
|
||||||
|
trkseg_ids,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Selection::Waypoints { file_id } | Selection::Waypoint { file_id, .. } => {
|
||||||
|
if files.contains_key(file_id) {
|
||||||
|
Selection::File {
|
||||||
|
file_ids: [*file_id].into(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Selection::Empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Selection::Empty => Selection::Empty,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::{FileId, Load, WaypointChunk, Waypoints, engine::command::fixture::Fixture};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn loaded() -> (Fixture, FileId) {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
|
||||||
|
Load {
|
||||||
|
data: &data,
|
||||||
|
name: "file",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let id = fx.order.0[0];
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
};
|
||||||
|
(fx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bounds(w: f64, s: f64, e: f64, n: f64) -> LngLatBounds {
|
||||||
|
LngLatBounds {
|
||||||
|
sw: LngLat { lng: w, lat: s },
|
||||||
|
ne: LngLat { lng: e, lat: n },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean(bounds: LngLatBounds, type_: CleanType) -> Clean {
|
||||||
|
Clean {
|
||||||
|
bounds,
|
||||||
|
type_,
|
||||||
|
trkpt: true,
|
||||||
|
wpt: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(fx: &Fixture, id: FileId) -> usize {
|
||||||
|
fx.files
|
||||||
|
.get(&id)
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|file| &file.trk)
|
||||||
|
.flat_map(|trk| &trk.trkseg)
|
||||||
|
.map(|seg| seg.len())
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_inside_everything_removes_all_trackpoints() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
assert!(count(&fx, id) > 0);
|
||||||
|
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Inside)
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count(&fx, id), 0);
|
||||||
|
// nothing is left of the file
|
||||||
|
assert!(!fx.files.contains_key(&id));
|
||||||
|
assert!(fx.order.0.is_empty());
|
||||||
|
assert_eq!(fx.selection, Selection::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_emptied_segments_and_tracks_are_removed() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
let seg = &before.trk[0].trkseg[0];
|
||||||
|
let pts: Vec<_> = seg.iter().map(|p| p.coordinates).collect();
|
||||||
|
let mut b = LngLatBounds::default();
|
||||||
|
pts.iter().for_each(|c| b.extend(*c));
|
||||||
|
clean(b, CleanType::Inside).apply(&mut fx.state()).unwrap();
|
||||||
|
let after = &fx.files[&id];
|
||||||
|
assert!(after.trk.iter().all(|t| !t.trkseg.is_empty()));
|
||||||
|
assert!(
|
||||||
|
after
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.flat_map(|t| &t.trkseg)
|
||||||
|
.all(|s| !s.is_empty())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_outside_everything_changes_nothing() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
let result =
|
||||||
|
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Outside).apply(&mut fx.state());
|
||||||
|
assert!(matches!(result, Err(CommandError::NothingToDo)));
|
||||||
|
// the file was not even copied
|
||||||
|
assert!(std::rc::Rc::ptr_eq(&fx.files[&id], &before));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_partial_clean_only_removes_points_inside_and_bumps_revision() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
let first = before.trk[0].trkseg[0].iter().next().unwrap().coordinates;
|
||||||
|
let total = count(&fx, id);
|
||||||
|
// tiny rectangle around the first point
|
||||||
|
let b = bounds(
|
||||||
|
first.lng - 1e-9,
|
||||||
|
first.lat - 1e-9,
|
||||||
|
first.lng + 1e-9,
|
||||||
|
first.lat + 1e-9,
|
||||||
|
);
|
||||||
|
clean(b, CleanType::Inside).apply(&mut fx.state()).unwrap();
|
||||||
|
let removed = total - count(&fx, id);
|
||||||
|
assert!(removed >= 1);
|
||||||
|
let seg = &fx.files[&id].trk[0].trkseg[0];
|
||||||
|
assert_ne!(seg.rev_id, before.trk[0].trkseg[0].rev_id);
|
||||||
|
assert!(seg.iter().all(|pt| !b_contains(&b, pt.coordinates)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn b_contains(b: &LngLatBounds, c: LngLat) -> bool {
|
||||||
|
c.lng >= b.sw.lng && c.lng <= b.ne.lng && c.lat >= b.sw.lat && c.lat <= b.ne.lat
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_waypoints_flag_and_selection() {
|
||||||
|
let (mut fx, id) = loaded();
|
||||||
|
let mut file = (*fx.files[&id]).clone();
|
||||||
|
file.wpt = Waypoints::new([WaypointChunk {
|
||||||
|
wpt: vec![Waypoint::default(), Waypoint::default()],
|
||||||
|
..Default::default()
|
||||||
|
}]);
|
||||||
|
let ids: Vec<_> = file.wpt.iter().map(|w| w.id).collect();
|
||||||
|
fx.files.insert(id, std::rc::Rc::new(file));
|
||||||
|
let everywhere = bounds(-180.0, -90.0, 180.0, 90.0);
|
||||||
|
let wpt_count = |fx: &Fixture| fx.files.get(&id).map_or(0, |f| f.wpt.len());
|
||||||
|
|
||||||
|
// trackpoints only: waypoints are kept
|
||||||
|
Clean {
|
||||||
|
wpt: false,
|
||||||
|
..clean(everywhere, CleanType::Inside)
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(wpt_count(&fx), 2);
|
||||||
|
|
||||||
|
// one selected waypoint
|
||||||
|
fx.selection = Selection::Waypoint {
|
||||||
|
file_id: id,
|
||||||
|
wpt_ids: HashSet::from([ids[0]]),
|
||||||
|
};
|
||||||
|
clean(everywhere, CleanType::Inside)
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(wpt_count(&fx), 1);
|
||||||
|
|
||||||
|
// whole file
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
};
|
||||||
|
clean(everywhere, CleanType::Inside)
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(wpt_count(&fx), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
rc::Rc,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Apply, CommandError, File, FileId, SegmentLocation, Selection, State, TrackSegment,
|
||||||
|
TrackSegmentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Keeps the trackpoints `start` to `end` (both included) of the selection, which are counted
|
||||||
|
/// over its segments one after the other (as in the `RoutingBuffer`), and removes the others.
|
||||||
|
///
|
||||||
|
/// The segments that have no trackpoint left are removed. So are the tracks that have no segment
|
||||||
|
/// left, if tracks or files are selected (not if only segments are), and the files that have no
|
||||||
|
/// trackpoint left, if files are selected. A range that goes past the last trackpoint stops at it.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Crop {
|
||||||
|
pub start: u32,
|
||||||
|
pub end: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happens to a selected segment.
|
||||||
|
struct Plan {
|
||||||
|
location: SegmentLocation,
|
||||||
|
/// The trackpoints that are kept, `None` if the segment goes away.
|
||||||
|
keep: Option<(usize, usize)>,
|
||||||
|
len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for Crop {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let (start, end) = (self.start as usize, self.end as usize);
|
||||||
|
if start > end {
|
||||||
|
return Err(CommandError::InvalidData(
|
||||||
|
"invalid trackpoints range".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut offset = 0;
|
||||||
|
let plans: Vec<Plan> = state
|
||||||
|
.selection
|
||||||
|
.segment_locations(state.files, &state.order.0)
|
||||||
|
.into_iter()
|
||||||
|
.map(|location| {
|
||||||
|
let len =
|
||||||
|
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len();
|
||||||
|
let keep = (len > 0 && start < offset + len && end >= offset)
|
||||||
|
.then(|| (start.saturating_sub(offset), (end - offset).min(len - 1)));
|
||||||
|
offset += len;
|
||||||
|
Plan {
|
||||||
|
location,
|
||||||
|
keep,
|
||||||
|
len,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if plans
|
||||||
|
.iter()
|
||||||
|
.all(|plan| plan.keep == Some((0, plan.len.saturating_sub(1))))
|
||||||
|
{
|
||||||
|
// everything is kept as it is, or there is nothing
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the files and tracks to prune, the segments to remove
|
||||||
|
let (prune_tracks, prune_files) = match &*state.selection {
|
||||||
|
Selection::File { .. } => (true, true),
|
||||||
|
Selection::Track { .. } => (true, false),
|
||||||
|
_ => (false, false),
|
||||||
|
};
|
||||||
|
let mut files: HashMap<FileId, File> = HashMap::new();
|
||||||
|
let mut removed: HashSet<TrackSegmentId> = HashSet::new();
|
||||||
|
let mut had_points: HashSet<FileId> = HashSet::new();
|
||||||
|
for plan in &plans {
|
||||||
|
let SegmentLocation { file_id, trk, seg } = plan.location;
|
||||||
|
let file = files
|
||||||
|
.entry(file_id)
|
||||||
|
.or_insert_with(|| (*state.files[&file_id]).clone());
|
||||||
|
if plan.len > 0 {
|
||||||
|
had_points.insert(file_id);
|
||||||
|
}
|
||||||
|
let segment = &mut file.trk[trk].trkseg[seg];
|
||||||
|
match plan.keep {
|
||||||
|
None => {
|
||||||
|
removed.insert(segment.id);
|
||||||
|
}
|
||||||
|
Some((from, to)) => crop_segment(segment, from, to),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut gone: Vec<FileId> = vec![];
|
||||||
|
for (file_id, file) in files.iter_mut() {
|
||||||
|
// the tracks that lost segments
|
||||||
|
let touched: Vec<bool> = file
|
||||||
|
.trk
|
||||||
|
.iter_mut()
|
||||||
|
.map(|track| {
|
||||||
|
let before = track.trkseg.len();
|
||||||
|
track
|
||||||
|
.trkseg
|
||||||
|
.retain(|segment| !removed.contains(&segment.id));
|
||||||
|
before != track.trkseg.len()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if prune_tracks {
|
||||||
|
let mut touched = touched.into_iter();
|
||||||
|
file.trk
|
||||||
|
.retain(|track| !(touched.next().unwrap() && track.trkseg.is_empty()));
|
||||||
|
}
|
||||||
|
if prune_files && file.trk.is_empty() && had_points.contains(file_id) {
|
||||||
|
gone.push(*file_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (file_id, file) in files {
|
||||||
|
if gone.contains(&file_id) {
|
||||||
|
state.files.remove(&file_id);
|
||||||
|
state.order.0.retain(|id| *id != file_id);
|
||||||
|
} else {
|
||||||
|
state.files.insert(file_id, Rc::new(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the trackpoints `from` to `to` of a segment, which are in it. Only the chunks at the ends
|
||||||
|
/// are changed.
|
||||||
|
fn crop_segment(segment: &mut TrackSegment, from: usize, to: usize) {
|
||||||
|
let len = segment.len();
|
||||||
|
if from == 0 && to + 1 >= len {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
segment.splice(to + 1, len, vec![]);
|
||||||
|
segment.splice(0, from, vec![]);
|
||||||
|
segment.rev_id = Default::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::{Load, engine::command::fixture::Fixture};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn loaded(path: &str) -> (Fixture, FileId) {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let data = std::fs::read(path).unwrap();
|
||||||
|
Load {
|
||||||
|
data: &data,
|
||||||
|
name: "file",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let id = fx.order.0[0];
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
};
|
||||||
|
(fx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn crop(start: u32, end: u32) -> Crop {
|
||||||
|
Crop { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The longitudes of the trackpoints of a file, one list per segment.
|
||||||
|
fn paths(file: &File) -> Vec<Vec<f64>> {
|
||||||
|
file.trk
|
||||||
|
.iter()
|
||||||
|
.flat_map(|track| &track.trkseg)
|
||||||
|
.map(|segment| segment.iter().map(|p| p.coordinates.lng).collect())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crop_a_file_keeps_the_range_over_its_segments() {
|
||||||
|
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||||
|
let before = paths(&fx.files[&id]);
|
||||||
|
let flat: Vec<f64> = before.iter().flatten().copied().collect();
|
||||||
|
let (start, end) = (2, flat.len() - 3);
|
||||||
|
|
||||||
|
crop(start as u32, end as u32)
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let after: Vec<f64> = paths(&fx.files[&id]).iter().flatten().copied().collect();
|
||||||
|
assert_eq!(after, flat[start..=end]);
|
||||||
|
// the ends of the segments that are left are anchors
|
||||||
|
for track in &fx.files[&id].trk {
|
||||||
|
for segment in &track.trkseg {
|
||||||
|
assert!(!segment.is_empty());
|
||||||
|
assert_eq!(segment[0].anchor, Some(0));
|
||||||
|
assert_eq!(segment[segment.len() - 1].anchor, Some(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_what_is_outside_goes_away() {
|
||||||
|
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||||
|
let file = fx.files[&id].clone();
|
||||||
|
let lens: Vec<Vec<usize>> = file
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.trkseg.iter().map(|segment| segment.len()).collect())
|
||||||
|
.collect();
|
||||||
|
assert!(lens.len() >= 2);
|
||||||
|
// from the start of the last track to the end
|
||||||
|
let before_last: usize = lens[..lens.len() - 1].iter().flatten().sum();
|
||||||
|
crop(before_last as u32, u32::MAX)
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let after = &fx.files[&id];
|
||||||
|
// the other tracks are gone, the last one is whole
|
||||||
|
assert_eq!(after.trk.len(), 1);
|
||||||
|
assert_eq!(after.trk[0].id, file.trk.last().unwrap().id);
|
||||||
|
assert_eq!(
|
||||||
|
after.trk[0]
|
||||||
|
.trkseg
|
||||||
|
.iter()
|
||||||
|
.map(|segment| segment.len())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
*lens.last().unwrap()
|
||||||
|
);
|
||||||
|
// the selection followed
|
||||||
|
assert!(matches!(&fx.selection, Selection::File { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crop_inside_a_segment_changes_it_only() {
|
||||||
|
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||||
|
let file = fx.files[&id].clone();
|
||||||
|
let trk = &file.trk[0];
|
||||||
|
assert!(trk.trkseg.len() >= 2);
|
||||||
|
fx.selection = Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: trk.id,
|
||||||
|
trkseg_ids: HashSet::from([trk.trkseg[1].id]),
|
||||||
|
};
|
||||||
|
let len = trk.trkseg[1].len();
|
||||||
|
assert!(len > 4);
|
||||||
|
crop(1, len as u32 - 2).apply(&mut fx.state()).unwrap();
|
||||||
|
|
||||||
|
let after = &fx.files[&id].trk[0];
|
||||||
|
assert_eq!(after.trkseg[1].len(), len - 2);
|
||||||
|
assert_ne!(after.trkseg[1].rev_id, trk.trkseg[1].rev_id);
|
||||||
|
// the other segments are not in the selection, whatever the range
|
||||||
|
assert_eq!(after.trkseg[0].rev_id, trk.trkseg[0].rev_id);
|
||||||
|
assert_eq!(fx.files[&id].trk.len(), file.trk.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_segments_out_of_the_range_are_removed_but_not_their_track() {
|
||||||
|
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||||
|
let trk = fx.files[&id].trk[0].clone();
|
||||||
|
assert!(trk.trkseg.len() >= 2);
|
||||||
|
fx.selection = Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: trk.id,
|
||||||
|
trkseg_ids: trk.trkseg.iter().map(|s| s.id).collect(),
|
||||||
|
};
|
||||||
|
// only the first segment, entirely
|
||||||
|
let first = trk.trkseg[0].len();
|
||||||
|
crop(0, first as u32 - 1).apply(&mut fx.state()).unwrap();
|
||||||
|
let after = &fx.files[&id].trk[0];
|
||||||
|
assert_eq!(after.trkseg.len(), 1);
|
||||||
|
assert_eq!(after.trkseg[0].id, trk.trkseg[0].id);
|
||||||
|
|
||||||
|
// nothing at all: the segments go, the track stays
|
||||||
|
crop(100_000, 100_001).apply(&mut fx.state()).unwrap();
|
||||||
|
assert!(fx.files[&id].trk[0].trkseg.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_files_out_of_the_range_are_deleted() {
|
||||||
|
let (mut fx, first) = loaded("data/simple.gpx");
|
||||||
|
let data = std::fs::read("data/with_time.gpx").unwrap();
|
||||||
|
Load {
|
||||||
|
data: &data,
|
||||||
|
name: "second",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let second = fx.order.0[1];
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([first, second]),
|
||||||
|
};
|
||||||
|
let first_len: usize = fx.files[&first]
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.flat_map(|t| &t.trkseg)
|
||||||
|
.map(|segment| segment.len())
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
crop(first_len as u32 + 1, u32::MAX)
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert!(!fx.files.contains_key(&first));
|
||||||
|
assert_eq!(fx.order.0, [second]);
|
||||||
|
let kept: usize = fx.files[&second]
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.flat_map(|t| &t.trkseg)
|
||||||
|
.map(|segment| segment.len())
|
||||||
|
.sum();
|
||||||
|
assert!(kept > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nothing_to_do_or_invalid() {
|
||||||
|
let (mut fx, id) = loaded("data/simple.gpx");
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
// the whole selection
|
||||||
|
assert_eq!(
|
||||||
|
crop(0, u32::MAX).apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
crop(5, 2).apply(&mut fx.state()),
|
||||||
|
Err(CommandError::InvalidData(_))
|
||||||
|
));
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
assert_eq!(
|
||||||
|
crop(0, 3).apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
fx.selection = Selection::Waypoints { file_id: id };
|
||||||
|
assert_eq!(
|
||||||
|
crop(0, 3).apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
use crate::{Apply, CommandError, FileId, State, WaypointId, set_waypoint_fields, update_waypoint};
|
||||||
|
|
||||||
|
/// Changes a waypoint of a file, whatever is selected (what the form of the waypoint tool does):
|
||||||
|
/// its name, description, icon, link, position and elevation. The strings that are empty remove
|
||||||
|
/// the field.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EditWaypoint<'a> {
|
||||||
|
pub file_id: FileId,
|
||||||
|
pub waypoint_id: WaypointId,
|
||||||
|
pub lng: f64,
|
||||||
|
pub lat: f64,
|
||||||
|
pub ele: f64,
|
||||||
|
pub name: &'a str,
|
||||||
|
pub desc: &'a str,
|
||||||
|
pub icon: &'a str,
|
||||||
|
pub link: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for EditWaypoint<'_> {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
update_waypoint(state, self.file_id, self.waypoint_id, |wpt| {
|
||||||
|
set_waypoint_fields(
|
||||||
|
wpt,
|
||||||
|
(self.lng, self.lat, self.ele),
|
||||||
|
self.name,
|
||||||
|
self.desc,
|
||||||
|
self.icon,
|
||||||
|
self.link,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
File, Link, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_the_given_waypoint() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let mut file = File::default();
|
||||||
|
file.wpt.push(WaypointChunk {
|
||||||
|
wpt: vec![
|
||||||
|
Waypoint {
|
||||||
|
name: Some("old".into()),
|
||||||
|
desc: Some("old description".into()),
|
||||||
|
cmt: Some("old comment".into()),
|
||||||
|
sym: Some("Flag".into()),
|
||||||
|
links: vec![
|
||||||
|
Link {
|
||||||
|
href: "https://old".into(),
|
||||||
|
text: None,
|
||||||
|
},
|
||||||
|
Link {
|
||||||
|
href: "https://second".into(),
|
||||||
|
text: Some("second".into()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Waypoint {
|
||||||
|
name: Some("other".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let id = file.id;
|
||||||
|
let ids: Vec<_> = waypoint_ids(&file).collect();
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
|
||||||
|
EditWaypoint {
|
||||||
|
file_id: id,
|
||||||
|
waypoint_id: ids[0],
|
||||||
|
lng: 4.5,
|
||||||
|
lat: 50.5,
|
||||||
|
ele: 12.0,
|
||||||
|
name: "new",
|
||||||
|
desc: "new description",
|
||||||
|
icon: "Summit",
|
||||||
|
link: "",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let wpts: Vec<_> = fx.files[&id].wpt.iter().collect();
|
||||||
|
let edited = wpts[0];
|
||||||
|
assert_eq!(edited.id, ids[0]);
|
||||||
|
assert_eq!(edited.name.as_deref(), Some("new"));
|
||||||
|
assert_eq!(edited.desc.as_deref(), Some("new description"));
|
||||||
|
assert_eq!(edited.cmt.as_deref(), Some("new description"));
|
||||||
|
assert_eq!(edited.sym.as_deref(), Some("Summit"));
|
||||||
|
// an empty link removes the first one, the others are not the form's business
|
||||||
|
assert_eq!(edited.links.len(), 1);
|
||||||
|
assert_eq!(edited.links[0].href, "https://second");
|
||||||
|
assert_eq!(
|
||||||
|
(edited.coordinates.lng, edited.coordinates.lat, edited.ele),
|
||||||
|
(4.5, 50.5, 12.0)
|
||||||
|
);
|
||||||
|
// the other waypoint is as it was
|
||||||
|
assert_eq!(wpts[1].name.as_deref(), Some("other"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_link_of_the_form_is_the_first_one() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let mut file = File::default();
|
||||||
|
file.wpt.push(WaypointChunk {
|
||||||
|
wpt: vec![Waypoint {
|
||||||
|
links: vec![
|
||||||
|
Link {
|
||||||
|
href: "https://first".into(),
|
||||||
|
text: Some("first".into()),
|
||||||
|
},
|
||||||
|
Link {
|
||||||
|
href: "https://second".into(),
|
||||||
|
text: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let (id, wpt_id) = (file.id, waypoint_ids(&file).next().unwrap());
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
let edit = |fx: &mut Fixture, link: &str| {
|
||||||
|
EditWaypoint {
|
||||||
|
file_id: id,
|
||||||
|
waypoint_id: wpt_id,
|
||||||
|
lng: 0.0,
|
||||||
|
lat: 0.0,
|
||||||
|
ele: 0.0,
|
||||||
|
name: "",
|
||||||
|
desc: "",
|
||||||
|
icon: "",
|
||||||
|
link,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
fx.files[&id].wpt.iter().next().unwrap().links.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// the same link keeps its text
|
||||||
|
let links = edit(&mut fx, "https://first");
|
||||||
|
assert_eq!(links.len(), 2);
|
||||||
|
assert_eq!(links[0].text.as_deref(), Some("first"));
|
||||||
|
// another one replaces the first, and the text that described the old one
|
||||||
|
let links = edit(&mut fx, "https://other");
|
||||||
|
assert_eq!(links.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
(links[0].href.as_str(), links[0].text.clone()),
|
||||||
|
("https://other", None)
|
||||||
|
);
|
||||||
|
assert_eq!(links[1].href, "https://second");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
use crate::{Apply, CommandError, State, update_segments};
|
||||||
|
|
||||||
|
/// Sets the elevation of the trackpoints of the selected segments (the ones of the statistics of
|
||||||
|
/// the selection, in the same order): `ele` has one elevation per trackpoint.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Elevation<'a> {
|
||||||
|
pub ele: &'a [f64],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for Elevation<'_> {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let locations = state
|
||||||
|
.selection
|
||||||
|
.segment_locations(state.files, &state.order.0);
|
||||||
|
let count: usize = locations
|
||||||
|
.iter()
|
||||||
|
.map(|location| {
|
||||||
|
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
if count == 0 {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
if count != self.ele.len() {
|
||||||
|
return Err(CommandError::InvalidData(format!(
|
||||||
|
"{} elevations for {count} trackpoints",
|
||||||
|
self.ele.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ele = self.ele.iter().copied();
|
||||||
|
update_segments(state, &locations, |_, segment| {
|
||||||
|
segment.update_all(|_, pt| pt.ele = ele.next().unwrap_or(pt.ele));
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn loaded(path: &str) -> (Fixture, FileId) {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let data = std::fs::read(path).unwrap();
|
||||||
|
Load {
|
||||||
|
data: &data,
|
||||||
|
name: "file",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let id = fx.order.0[0];
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
};
|
||||||
|
(fx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trackpoint_elevations(fx: &Fixture, id: FileId) -> Vec<f64> {
|
||||||
|
fx.files[&id]
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.flat_map(|trk| {
|
||||||
|
trk.trkseg
|
||||||
|
.iter()
|
||||||
|
.flat_map(|seg| seg.iter().map(|pt| pt.ele))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_file_gets_elevation_on_all_its_trackpoints() {
|
||||||
|
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||||
|
let count = trackpoint_elevations(&fx, id).len();
|
||||||
|
let ele: Vec<f64> = (0..count).map(|i| 100.0 + i as f64).collect();
|
||||||
|
|
||||||
|
Elevation { ele: &ele }.apply(&mut fx.state()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(trackpoint_elevations(&fx, id), ele);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_segments_only_touch_their_trackpoints() {
|
||||||
|
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||||
|
let before = trackpoint_elevations(&fx, id);
|
||||||
|
let track = &fx.files[&id].trk[0];
|
||||||
|
fx.selection = Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: track.id,
|
||||||
|
trkseg_ids: HashSet::from([track.trkseg[0].id]),
|
||||||
|
};
|
||||||
|
let rev_ids: Vec<_> = track.trkseg.iter().map(|seg| seg.rev_id).collect();
|
||||||
|
let len = track.trkseg[0].len();
|
||||||
|
|
||||||
|
Elevation {
|
||||||
|
ele: &vec![5.0; len],
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let after = trackpoint_elevations(&fx, id);
|
||||||
|
assert_eq!(after[..len], vec![5.0; len]);
|
||||||
|
assert_eq!(after[len..], before[len..]);
|
||||||
|
let segments = &fx.files[&id].trk[0].trkseg;
|
||||||
|
assert_ne!(segments[0].rev_id, rev_ids[0]);
|
||||||
|
assert_eq!(segments[1].rev_id, rev_ids[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrong_number_of_elevations() {
|
||||||
|
let (mut fx, id) = loaded("data/simple.gpx");
|
||||||
|
let before = trackpoint_elevations(&fx, id);
|
||||||
|
assert!(matches!(
|
||||||
|
Elevation { ele: &[1.0] }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::InvalidData(_))
|
||||||
|
));
|
||||||
|
assert_eq!(trackpoint_elevations(&fx, id), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nothing_selected() {
|
||||||
|
let (mut fx, _) = loaded("data/simple.gpx");
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
assert!(matches!(
|
||||||
|
Elevation { ele: &[] }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
use std::{collections::HashSet, rc::Rc};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Apply, CommandError, File, FileId, LngLat, LngLatBounds, Selection, State, Track, TrackId,
|
||||||
|
TrackSegment, Waypoint, Waypoints, copy_file, copy_waypoint, crossarc_lnglat, distance,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Splits the selected elements that are made of several segments into elements that have one
|
||||||
|
/// each:
|
||||||
|
///
|
||||||
|
/// - files: a file with several tracks becomes one file per track, in which the track is split
|
||||||
|
/// into one track per segment. A file with a single track becomes one file per segment. The new
|
||||||
|
/// files are in the place of the file, which goes, and are selected. They are named after the
|
||||||
|
/// track, or the file, and numbered. The waypoints go to the files that have the segments
|
||||||
|
/// closest to them (to the first file if the file has no trackpoint).
|
||||||
|
/// - tracks: each selected track is replaced by one track per segment, which are selected. They
|
||||||
|
/// are named after the track and numbered.
|
||||||
|
///
|
||||||
|
/// What has a single segment is left as it is. There is nothing to do if nothing was changed.
|
||||||
|
///
|
||||||
|
/// With `keep_originals`, the files that are extracted stay where they are, and the new files come
|
||||||
|
/// right after each of them, and only the new files are selected. Extracting
|
||||||
|
/// tracks changes the file in place anyway, so the flag has no effect on it.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Extract {
|
||||||
|
pub keep_originals: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for Extract {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let selection = state.selection.clone();
|
||||||
|
match &selection {
|
||||||
|
Selection::File { file_ids } => extract_files(state, file_ids, self.keep_originals),
|
||||||
|
Selection::Track { file_id, trk_ids } => extract_tracks(state, *file_id, trk_ids),
|
||||||
|
_ => Err(CommandError::NothingToDo),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_files(
|
||||||
|
state: &mut State,
|
||||||
|
file_ids: &HashSet<FileId>,
|
||||||
|
keep_originals: bool,
|
||||||
|
) -> Result<(), CommandError> {
|
||||||
|
// the files that have something to extract, with the files that come out of them
|
||||||
|
let mut extracted: Vec<(FileId, Vec<File>)> = vec![];
|
||||||
|
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
|
||||||
|
let file = &state.files[id];
|
||||||
|
let segments: usize = file.trk.iter().map(|track| track.trkseg.len()).sum();
|
||||||
|
if segments > 1 {
|
||||||
|
let mut files = extract_file(file);
|
||||||
|
if keep_originals {
|
||||||
|
// the tracks and segments are the ones of the file, which stays: they need new ids
|
||||||
|
files = files.iter().map(copy_file).collect();
|
||||||
|
}
|
||||||
|
extracted.push((*id, files));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if extracted.is_empty() {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut order = vec![];
|
||||||
|
for id in state.order.0.iter() {
|
||||||
|
match extracted.iter().find(|(original, _)| original == id) {
|
||||||
|
Some((_, files)) => {
|
||||||
|
if keep_originals {
|
||||||
|
order.push(*id);
|
||||||
|
}
|
||||||
|
order.extend(files.iter().map(|file| file.id));
|
||||||
|
}
|
||||||
|
None => order.push(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// the files that are kept stay selected, unless the originals are kept: then only what is new is
|
||||||
|
let mut selected: HashSet<FileId> = if keep_originals {
|
||||||
|
HashSet::new()
|
||||||
|
} else {
|
||||||
|
file_ids.clone()
|
||||||
|
};
|
||||||
|
for (original, files) in extracted {
|
||||||
|
if !keep_originals {
|
||||||
|
state.files.remove(&original);
|
||||||
|
}
|
||||||
|
selected.remove(&original);
|
||||||
|
for file in files {
|
||||||
|
selected.insert(file.id);
|
||||||
|
state.files.insert(file.id, Rc::new(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.order.0 = order;
|
||||||
|
*state.selection = Selection::File { file_ids: selected };
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The files that a file with several segments becomes.
|
||||||
|
fn extract_file(file: &File) -> Vec<File> {
|
||||||
|
// for each new file: its tracks and its name, and the segments that go with it
|
||||||
|
struct Part {
|
||||||
|
tracks: Vec<Track>,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
let (parts, owner): (Vec<Part>, Vec<usize>) = if file.trk.len() > 1 {
|
||||||
|
// one file per track, the segments of a track follow it
|
||||||
|
let parts = file
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, track)| Part {
|
||||||
|
tracks: split_track(track),
|
||||||
|
name: track
|
||||||
|
.info
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{} ({})", file.info.name, i + 1)),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let owner = file
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.flat_map(|(i, track)| track.trkseg.iter().map(move |_| i))
|
||||||
|
.collect();
|
||||||
|
(parts, owner)
|
||||||
|
} else {
|
||||||
|
// one file per segment
|
||||||
|
let track = &file.trk[0];
|
||||||
|
let base = track
|
||||||
|
.info
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| file.info.name.clone());
|
||||||
|
let parts = track
|
||||||
|
.trkseg
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, segment)| Part {
|
||||||
|
tracks: vec![Track {
|
||||||
|
id: if i == 0 { track.id } else { Default::default() },
|
||||||
|
trkseg: vec![segment.clone()],
|
||||||
|
..track.clone()
|
||||||
|
}],
|
||||||
|
name: format!("{base} ({})", i + 1),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let owner = (0..track.trkseg.len()).collect();
|
||||||
|
(parts, owner)
|
||||||
|
};
|
||||||
|
|
||||||
|
// the waypoints go with the segments that are the closest to them
|
||||||
|
let segments: Vec<&TrackSegment> = file.trk.iter().flat_map(|track| &track.trkseg).collect();
|
||||||
|
let mut waypoints: Vec<Vec<Waypoint>> = vec![vec![]; parts.len()];
|
||||||
|
for waypoint in file.wpt.iter() {
|
||||||
|
let mut owners: Vec<usize> = closest_segments(&segments, waypoint.coordinates)
|
||||||
|
.into_iter()
|
||||||
|
.map(|segment| owner[segment])
|
||||||
|
.collect();
|
||||||
|
owners.sort_unstable();
|
||||||
|
owners.dedup();
|
||||||
|
if owners.is_empty() {
|
||||||
|
owners.push(0);
|
||||||
|
}
|
||||||
|
for (n, part) in owners.into_iter().enumerate() {
|
||||||
|
// a waypoint that goes to several files has a new id in all but the first
|
||||||
|
waypoints[part].push(if n == 0 {
|
||||||
|
waypoint.clone()
|
||||||
|
} else {
|
||||||
|
copy_waypoint(waypoint)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts
|
||||||
|
.into_iter()
|
||||||
|
.zip(waypoints)
|
||||||
|
.map(|(part, waypoints)| {
|
||||||
|
let mut extracted = File {
|
||||||
|
id: Default::default(),
|
||||||
|
trk: part.tracks,
|
||||||
|
wpt: Waypoints::default(),
|
||||||
|
..file.clone()
|
||||||
|
};
|
||||||
|
extracted.info.name = part.name;
|
||||||
|
extracted.wpt.insert_at(0, waypoints);
|
||||||
|
extracted
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tracks(
|
||||||
|
state: &mut State,
|
||||||
|
file_id: FileId,
|
||||||
|
trk_ids: &HashSet<TrackId>,
|
||||||
|
) -> Result<(), CommandError> {
|
||||||
|
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||||
|
let mut changed = false;
|
||||||
|
let mut tracks = vec![];
|
||||||
|
let mut selected: HashSet<TrackId> = trk_ids.clone();
|
||||||
|
for track in &file.trk {
|
||||||
|
if trk_ids.contains(&track.id) && track.trkseg.len() > 1 {
|
||||||
|
changed = true;
|
||||||
|
selected.remove(&track.id);
|
||||||
|
let pieces = split_track(track);
|
||||||
|
selected.extend(pieces.iter().map(|piece| piece.id));
|
||||||
|
tracks.extend(pieces);
|
||||||
|
} else {
|
||||||
|
tracks.push(track.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
let mut file = (**file).clone();
|
||||||
|
file.trk = tracks;
|
||||||
|
state.files.insert(file_id, Rc::new(file));
|
||||||
|
*state.selection = Selection::Track {
|
||||||
|
file_id,
|
||||||
|
trk_ids: selected,
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tracks that a track becomes: one per segment, the first one with the id of the track. The
|
||||||
|
/// others have the same info, with the name of the track and the number of the segment, if it has
|
||||||
|
/// a name. A track with at most one segment stays as it is.
|
||||||
|
fn split_track(track: &Track) -> Vec<Track> {
|
||||||
|
if track.trkseg.len() <= 1 {
|
||||||
|
return vec![track.clone()];
|
||||||
|
}
|
||||||
|
track
|
||||||
|
.trkseg
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, segment)| {
|
||||||
|
let mut piece = Track {
|
||||||
|
id: if i == 0 { track.id } else { Default::default() },
|
||||||
|
trkseg: vec![segment.clone()],
|
||||||
|
..track.clone()
|
||||||
|
};
|
||||||
|
piece.info.name = track
|
||||||
|
.info
|
||||||
|
.name
|
||||||
|
.as_ref()
|
||||||
|
.map(|name| format!("{name} ({})", i + 1));
|
||||||
|
piece
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The indices of the segments that have the trackpoint that is the closest to `point`, which are
|
||||||
|
/// several when the closest trackpoints are as close.
|
||||||
|
///
|
||||||
|
/// The segments are looked at from the closest to the farthest bounds, which is how they are
|
||||||
|
/// left out: the ones whose bounds are farther than the closest trackpoint found.
|
||||||
|
fn closest_segments(segments: &[&TrackSegment], point: LngLat) -> Vec<usize> {
|
||||||
|
let mut by_bounds: Vec<(f64, usize)> = segments
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, segment)| !segment.is_empty())
|
||||||
|
.map(|(i, segment)| {
|
||||||
|
let mut bounds = LngLatBounds::default();
|
||||||
|
segment
|
||||||
|
.iter()
|
||||||
|
.for_each(|trkpt| bounds.extend(trkpt.coordinates));
|
||||||
|
(distance_to_bounds(&bounds, point), i)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
by_bounds.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||||
|
|
||||||
|
let mut closest = f64::MAX;
|
||||||
|
let mut indices: Vec<usize> = vec![];
|
||||||
|
for (bounds_distance, i) in by_bounds {
|
||||||
|
if bounds_distance > closest {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for trkpt in segments[i].iter() {
|
||||||
|
// in meters, like the distances to the bounds
|
||||||
|
let d = distance(trkpt.coordinates, point) * 1000.0;
|
||||||
|
if d < closest {
|
||||||
|
closest = d;
|
||||||
|
indices = vec![i];
|
||||||
|
} else if d == closest {
|
||||||
|
indices.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
indices.sort_unstable();
|
||||||
|
indices.dedup();
|
||||||
|
indices
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 0 inside the bounds, else the distance in meters to the closest side.
|
||||||
|
fn distance_to_bounds(bounds: &LngLatBounds, point: LngLat) -> f64 {
|
||||||
|
let (sw, ne) = (bounds.sw, bounds.ne);
|
||||||
|
if (sw.lng..=ne.lng).contains(&point.lng) && (sw.lat..=ne.lat).contains(&point.lat) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let nw = LngLat {
|
||||||
|
lng: sw.lng,
|
||||||
|
lat: ne.lat,
|
||||||
|
};
|
||||||
|
let se = LngLat {
|
||||||
|
lng: ne.lng,
|
||||||
|
lat: sw.lat,
|
||||||
|
};
|
||||||
|
[(nw, ne), (ne, se), (se, sw), (sw, nw)]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(a, b)| crossarc_lnglat(a, b, point))
|
||||||
|
.fold(f64::MAX, f64::min)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::{TrackInfo, Trackpoint, engine::command::fixture::Fixture};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A segment of 5 trackpoints along the equator, from the longitude `from`.
|
||||||
|
fn segment(from: f64) -> TrackSegment {
|
||||||
|
let mut segment = TrackSegment::default();
|
||||||
|
let points = (0..5)
|
||||||
|
.map(|i| Trackpoint {
|
||||||
|
coordinates: LngLat {
|
||||||
|
lng: from + i as f64 * 0.001,
|
||||||
|
lat: 0.0,
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
segment.splice(0, 0, points);
|
||||||
|
segment
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track(name: Option<&str>, from: &[f64]) -> Track {
|
||||||
|
Track {
|
||||||
|
info: TrackInfo {
|
||||||
|
name: name.map(str::to_string),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
trkseg: from.iter().map(|from| segment(*from)).collect(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn waypoint(name: &str, lng: f64) -> Waypoint {
|
||||||
|
Waypoint {
|
||||||
|
name: Some(name.into()),
|
||||||
|
coordinates: LngLat { lng, lat: 0.001 },
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture(file: File) -> (Fixture, FileId) {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let id = file.id;
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
fx.order.0.push(id);
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: [id].into(),
|
||||||
|
};
|
||||||
|
(fx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file(name: &str, tracks: Vec<Track>, waypoints: Vec<Waypoint>) -> File {
|
||||||
|
let mut file = File {
|
||||||
|
trk: tracks,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
file.info.name = name.into();
|
||||||
|
file.wpt.insert_at(0, waypoints);
|
||||||
|
file
|
||||||
|
}
|
||||||
|
|
||||||
|
fn names(fx: &Fixture) -> Vec<String> {
|
||||||
|
fx.order
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|id| fx.files[id].info.name.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_file_with_one_track_becomes_one_file_per_segment() {
|
||||||
|
let original = file(
|
||||||
|
"route",
|
||||||
|
vec![track(Some("climb"), &[0.0, 10.0, 20.0])],
|
||||||
|
// near the first segment, near the last one, and in between, closer to the middle one
|
||||||
|
vec![
|
||||||
|
waypoint("a", 0.002),
|
||||||
|
waypoint("c", 20.002),
|
||||||
|
waypoint("b", 10.0),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let track_id = original.trk[0].id;
|
||||||
|
let segment_ids: Vec<_> = original.trk[0].trkseg.iter().map(|s| s.id).collect();
|
||||||
|
let (mut fx, id) = fixture(original);
|
||||||
|
let other = file("other", vec![track(None, &[50.0])], vec![]);
|
||||||
|
let other_id = other.id;
|
||||||
|
fx.files.insert(other_id, Rc::new(other));
|
||||||
|
fx.order.0.push(other_id);
|
||||||
|
|
||||||
|
Extract {
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// in the place of the file, in order, named after the track
|
||||||
|
assert!(!fx.files.contains_key(&id));
|
||||||
|
assert_eq!(names(&fx), ["climb (1)", "climb (2)", "climb (3)", "other"]);
|
||||||
|
assert_eq!(fx.order.0[3], other_id);
|
||||||
|
for (i, file_id) in fx.order.0[..3].iter().enumerate() {
|
||||||
|
let file = &fx.files[file_id];
|
||||||
|
assert_eq!(file.trk.len(), 1);
|
||||||
|
assert_eq!(file.trk[0].trkseg.len(), 1);
|
||||||
|
assert_eq!(file.trk[0].trkseg[0].id, segment_ids[i]);
|
||||||
|
assert_eq!(file.trk[0].info.name.as_deref(), Some("climb"));
|
||||||
|
}
|
||||||
|
assert_eq!(fx.files[&fx.order.0[0]].trk[0].id, track_id);
|
||||||
|
// the waypoints are with the closest segment
|
||||||
|
let waypoints = |i: usize| -> Vec<String> {
|
||||||
|
fx.files[&fx.order.0[i]]
|
||||||
|
.wpt
|
||||||
|
.iter()
|
||||||
|
.map(|w| w.name.clone().unwrap())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
assert_eq!(waypoints(0), ["a"]);
|
||||||
|
assert_eq!(waypoints(1), ["b"]);
|
||||||
|
assert_eq!(waypoints(2), ["c"]);
|
||||||
|
// the new files are selected, the other one is not
|
||||||
|
assert!(matches!(&fx.selection, Selection::File { file_ids }
|
||||||
|
if file_ids.len() == 3 && !file_ids.contains(&other_id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extracting_files_keeping_the_originals() {
|
||||||
|
let original = file(
|
||||||
|
"route",
|
||||||
|
vec![track(Some("climb"), &[0.0, 10.0, 20.0])],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let segment_ids: Vec<_> = original.trk[0].trkseg.iter().map(|s| s.id).collect();
|
||||||
|
let (mut fx, id) = fixture(original);
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
let single = file("single", vec![track(None, &[50.0])], vec![]);
|
||||||
|
let single_id = single.id;
|
||||||
|
fx.files.insert(single_id, Rc::new(single));
|
||||||
|
fx.order.0.push(single_id);
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id, single_id]),
|
||||||
|
};
|
||||||
|
|
||||||
|
Extract {
|
||||||
|
keep_originals: true,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// the file stays, the new ones come right after it
|
||||||
|
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||||
|
assert_eq!(
|
||||||
|
names(&fx),
|
||||||
|
["route", "climb (1)", "climb (2)", "climb (3)", "single"]
|
||||||
|
);
|
||||||
|
assert_eq!(fx.order.0[0], id);
|
||||||
|
assert_eq!(fx.order.0[4], single_id);
|
||||||
|
// the segments are copies, not the ones of the file
|
||||||
|
for new_id in &fx.order.0[1..4] {
|
||||||
|
let segment = fx.files[new_id].trk[0].trkseg[0].id;
|
||||||
|
assert!(!segment_ids.contains(&segment));
|
||||||
|
}
|
||||||
|
// only the new files are selected
|
||||||
|
assert!(matches!(&fx.selection, Selection::File { file_ids }
|
||||||
|
if *file_ids == fx.order.0[1..4].iter().copied().collect::<HashSet<_>>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_file_with_several_tracks_becomes_one_file_per_track() {
|
||||||
|
let original = file(
|
||||||
|
"route",
|
||||||
|
vec![
|
||||||
|
track(Some("first"), &[0.0, 10.0]),
|
||||||
|
track(None, &[20.0]),
|
||||||
|
track(Some("third"), &[30.0, 40.0]),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let ids: Vec<_> = original.trk.iter().map(|t| t.id).collect();
|
||||||
|
let (mut fx, _) = fixture(original);
|
||||||
|
Extract {
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// named after the track, or the file
|
||||||
|
assert_eq!(names(&fx), ["first", "route (2)", "third"]);
|
||||||
|
let tracks = |i: usize| &fx.files[&fx.order.0[i]].trk;
|
||||||
|
// a track with several segments is split in as many tracks
|
||||||
|
assert_eq!(tracks(0).len(), 2);
|
||||||
|
assert_eq!(tracks(0)[0].info.name.as_deref(), Some("first (1)"));
|
||||||
|
assert_eq!(tracks(0)[1].info.name.as_deref(), Some("first (2)"));
|
||||||
|
assert_eq!(tracks(0)[0].id, ids[0]);
|
||||||
|
// and one that has a single segment is not renamed
|
||||||
|
assert_eq!(tracks(1).len(), 1);
|
||||||
|
assert_eq!(tracks(1)[0].id, ids[1]);
|
||||||
|
assert_eq!(tracks(1)[0].info.name, None);
|
||||||
|
assert_eq!(tracks(2).len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_waypoints_with_closest_segments_that_tie_are_copied() {
|
||||||
|
// the segments share a trackpoint, where the waypoint is: it is as close to both
|
||||||
|
let a = segment(0.0);
|
||||||
|
let b = segment(a[4].coordinates.lng);
|
||||||
|
let shared = Waypoint {
|
||||||
|
name: Some("mid".into()),
|
||||||
|
coordinates: a[4].coordinates,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let original = file(
|
||||||
|
"f",
|
||||||
|
vec![Track {
|
||||||
|
trkseg: vec![a, b],
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
vec![shared, waypoint("lost", 100.0)],
|
||||||
|
);
|
||||||
|
let (mut fx, _) = fixture(original);
|
||||||
|
Extract {
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let first = &fx.files[&fx.order.0[0]];
|
||||||
|
let second = &fx.files[&fx.order.0[1]];
|
||||||
|
// the tie goes to both, with different ids
|
||||||
|
let mid_in = |f: &File| {
|
||||||
|
f.wpt
|
||||||
|
.iter()
|
||||||
|
.find(|w| w.name.as_deref() == Some("mid"))
|
||||||
|
.map(|w| w.id)
|
||||||
|
};
|
||||||
|
assert!(mid_in(first).is_some() && mid_in(second).is_some());
|
||||||
|
assert_ne!(mid_in(first), mid_in(second));
|
||||||
|
// a far waypoint goes to its closest segment, which is the last one
|
||||||
|
assert!(second.wpt.iter().any(|w| w.name.as_deref() == Some("lost")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_waypoint_of_a_file_without_trackpoints_is_kept() {
|
||||||
|
let mut empty = TrackSegment::default();
|
||||||
|
empty.rev_id = Default::default();
|
||||||
|
let original = file(
|
||||||
|
"f",
|
||||||
|
vec![Track {
|
||||||
|
trkseg: vec![empty.clone(), TrackSegment::default()],
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
vec![waypoint("w", 1.0)],
|
||||||
|
);
|
||||||
|
let (mut fx, _) = fixture(original);
|
||||||
|
Extract {
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fx.files[&fx.order.0[0]].wpt.len(), 1);
|
||||||
|
assert_eq!(fx.files[&fx.order.0[1]].wpt.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tracks_in_place() {
|
||||||
|
let original = file(
|
||||||
|
"route",
|
||||||
|
vec![
|
||||||
|
track(Some("a"), &[0.0]),
|
||||||
|
track(Some("b"), &[10.0, 20.0, 30.0]),
|
||||||
|
track(None, &[40.0, 50.0]),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let ids: Vec<_> = original.trk.iter().map(|t| t.id).collect();
|
||||||
|
let (mut fx, id) = fixture(original);
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: [ids[1], ids[2]].into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Extract {
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let file = &fx.files[&id];
|
||||||
|
let names: Vec<_> = file.trk.iter().map(|t| t.info.name.clone()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
names,
|
||||||
|
[
|
||||||
|
Some("a".into()),
|
||||||
|
Some("b (1)".into()),
|
||||||
|
Some("b (2)".into()),
|
||||||
|
Some("b (3)".into()),
|
||||||
|
None,
|
||||||
|
None
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(file.trk.len(), 6);
|
||||||
|
assert!(file.trk.iter().all(|t| t.trkseg.len() == 1));
|
||||||
|
assert_eq!(file.trk[0].id, ids[0]);
|
||||||
|
assert_eq!(file.trk[1].id, ids[1]);
|
||||||
|
// the new tracks are selected
|
||||||
|
let Selection::Track { trk_ids, .. } = &fx.selection else {
|
||||||
|
panic!("tracks are selected");
|
||||||
|
};
|
||||||
|
assert_eq!(trk_ids.len(), 5);
|
||||||
|
assert!(trk_ids.contains(&ids[1]) && !trk_ids.contains(&ids[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nothing_to_extract() {
|
||||||
|
// a single segment
|
||||||
|
let original = file("f", vec![track(Some("t"), &[0.0])], vec![]);
|
||||||
|
let track_id = original.trk[0].id;
|
||||||
|
let segment_id = original.trk[0].trkseg[0].id;
|
||||||
|
let (mut fx, id) = fixture(original);
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
assert_eq!(
|
||||||
|
Extract {
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: [track_id].into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
Extract {
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
for selection in [
|
||||||
|
Selection::Empty,
|
||||||
|
Selection::Waypoints { file_id: id },
|
||||||
|
Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: track_id,
|
||||||
|
trkseg_ids: [segment_id].into(),
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
fx.selection = selection;
|
||||||
|
assert_eq!(
|
||||||
|
Extract {
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{Apply, CommandError, LngLat, SegmentLocation, State, Trackpoint, distance, projected};
|
||||||
|
|
||||||
|
/// Under this distance (in meters) to a trackpoint, that trackpoint becomes the anchor instead of
|
||||||
|
/// a new one being inserted next to it.
|
||||||
|
const SNAP_DISTANCE: f64 = 1.0;
|
||||||
|
|
||||||
|
/// Makes an anchor of the routing tool, shown at every zoom level, of the point of the selected
|
||||||
|
/// segments that is the closest to the given coordinates. That is the trackpoint itself if the
|
||||||
|
/// coordinates are on one, otherwise a new trackpoint is inserted on the path, at the position
|
||||||
|
/// of the coordinates projected on it, with an elevation and a time in between the ones of its
|
||||||
|
/// neighbours.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct InsertAnchor {
|
||||||
|
pub lng: f64,
|
||||||
|
pub lat: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the anchor goes: between the trackpoints `before` and `before + 1` of a segment, at
|
||||||
|
/// `ratio` of the distance from the first one, which is `projection`. `distance` is the one from
|
||||||
|
/// there to the coordinates, in meters.
|
||||||
|
struct Position {
|
||||||
|
location: SegmentLocation,
|
||||||
|
before: usize,
|
||||||
|
ratio: f64,
|
||||||
|
projection: LngLat,
|
||||||
|
distance: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for InsertAnchor {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let target = LngLat {
|
||||||
|
lng: self.lng,
|
||||||
|
lat: self.lat,
|
||||||
|
};
|
||||||
|
let mut closest: Option<Position> = None;
|
||||||
|
for location in state
|
||||||
|
.selection
|
||||||
|
.segment_locations(state.files, &state.order.0)
|
||||||
|
{
|
||||||
|
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
|
||||||
|
let mut points = segment.iter();
|
||||||
|
let Some(mut previous) = points.next() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if segment.len() == 1 {
|
||||||
|
// a single point is its own path
|
||||||
|
let d = distance(previous.coordinates, target) * 1000.0;
|
||||||
|
if closest.as_ref().is_none_or(|c| d < c.distance) {
|
||||||
|
closest = Some(Position {
|
||||||
|
location,
|
||||||
|
before: 0,
|
||||||
|
ratio: 0.0,
|
||||||
|
projection: previous.coordinates,
|
||||||
|
distance: d,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, point) in points.enumerate() {
|
||||||
|
let projection = projected(previous.coordinates, point.coordinates, target);
|
||||||
|
let d = distance(projection, target) * 1000.0;
|
||||||
|
if closest.as_ref().is_none_or(|c| d < c.distance) {
|
||||||
|
let length = distance(previous.coordinates, point.coordinates);
|
||||||
|
let ratio = if length > 0.0 {
|
||||||
|
distance(previous.coordinates, projection) / length
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
closest = Some(Position {
|
||||||
|
location,
|
||||||
|
before: i,
|
||||||
|
ratio,
|
||||||
|
projection,
|
||||||
|
distance: d,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
previous = point;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let closest = closest.ok_or(CommandError::NothingToDo)?;
|
||||||
|
|
||||||
|
let mut file = (*state.files[&closest.location.file_id]).clone();
|
||||||
|
let segment = &mut file.trk[closest.location.trk].trkseg[closest.location.seg];
|
||||||
|
let before = closest.before;
|
||||||
|
// on a trackpoint already, if it is closer than `SNAP_DISTANCE`
|
||||||
|
let snapped = if segment.len() == 1 {
|
||||||
|
Some(0)
|
||||||
|
} else {
|
||||||
|
let (a, b) = (&segment[before], &segment[before + 1]);
|
||||||
|
let meters = 1000.0 * distance(a.coordinates, b.coordinates);
|
||||||
|
if closest.ratio * meters < SNAP_DISTANCE {
|
||||||
|
Some(before)
|
||||||
|
} else if (1.0 - closest.ratio) * meters < SNAP_DISTANCE {
|
||||||
|
Some(before + 1)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(index) = snapped {
|
||||||
|
if segment[index].anchor == Some(0) {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
segment.set_anchor(index, 0);
|
||||||
|
} else {
|
||||||
|
let (a, b) = (&segment[before], &segment[before + 1]);
|
||||||
|
let ratio = closest.ratio;
|
||||||
|
let mut point: Trackpoint = a.clone();
|
||||||
|
point.coordinates = closest.projection;
|
||||||
|
point.ele = (1.0 - ratio) * a.ele + ratio * b.ele;
|
||||||
|
point.time = a
|
||||||
|
.time
|
||||||
|
.zip(b.time)
|
||||||
|
.map(|(a, b)| ((1.0 - ratio) * a as f64 + ratio * b as f64) as i64);
|
||||||
|
point.anchor = Some(0);
|
||||||
|
segment.splice(before + 1, before + 1, vec![point]);
|
||||||
|
}
|
||||||
|
segment.rev_id = Default::default();
|
||||||
|
state.files.insert(closest.location.file_id, Rc::new(file));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn loaded(path: &str) -> (Fixture, FileId) {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let data = std::fs::read(path).unwrap();
|
||||||
|
Load {
|
||||||
|
data: &data,
|
||||||
|
name: "file",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let id = fx.order.0[0];
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
};
|
||||||
|
(fx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_segment(fx: &Fixture, id: FileId) -> crate::TrackSegment {
|
||||||
|
fx.files[&id].trk[0].trkseg[0].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_point_is_inserted_between_its_neighbours() {
|
||||||
|
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||||
|
let before = first_segment(&fx, id);
|
||||||
|
let (a, b) = (&before[10], &before[11]);
|
||||||
|
// a bit off the line, closer to the middle of the two points
|
||||||
|
let target = LngLat {
|
||||||
|
lng: (a.coordinates.lng + b.coordinates.lng) / 2.0 + 1e-6,
|
||||||
|
lat: (a.coordinates.lat + b.coordinates.lat) / 2.0 + 1e-6,
|
||||||
|
};
|
||||||
|
InsertAnchor {
|
||||||
|
lng: target.lng,
|
||||||
|
lat: target.lat,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let after = first_segment(&fx, id);
|
||||||
|
assert_eq!(after.len(), before.len() + 1);
|
||||||
|
assert_ne!(after.rev_id, before.rev_id);
|
||||||
|
let point = &after[11];
|
||||||
|
assert_eq!(point.anchor, Some(0));
|
||||||
|
assert_eq!(after[10].coordinates.lng, a.coordinates.lng);
|
||||||
|
assert_eq!(after[12].coordinates.lng, b.coordinates.lng);
|
||||||
|
// between its neighbours, with the interpolated elevation and time
|
||||||
|
let ratio = (point.time.unwrap() - a.time.unwrap()) as f64
|
||||||
|
/ (b.time.unwrap() - a.time.unwrap()) as f64;
|
||||||
|
assert!((0.3..0.7).contains(&ratio), "{ratio}");
|
||||||
|
let expected = a.ele + ratio * (b.ele - a.ele);
|
||||||
|
assert!(
|
||||||
|
(point.ele - expected).abs() < 0.5,
|
||||||
|
"{} {expected}",
|
||||||
|
point.ele
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_trackpoint_that_is_hit_becomes_the_anchor() {
|
||||||
|
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||||
|
let before = first_segment(&fx, id);
|
||||||
|
assert_eq!(before[10].anchor.is_some(), before[10].anchor == Some(0));
|
||||||
|
let point = &before[10];
|
||||||
|
InsertAnchor {
|
||||||
|
lng: point.coordinates.lng,
|
||||||
|
lat: point.coordinates.lat,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let after = first_segment(&fx, id);
|
||||||
|
assert_eq!(after.len(), before.len());
|
||||||
|
let anchors = (0..after.len())
|
||||||
|
.filter(|i| after[*i].anchor == Some(0))
|
||||||
|
.count();
|
||||||
|
assert!(anchors >= 3, "{anchors}");
|
||||||
|
assert!(
|
||||||
|
after
|
||||||
|
.iter()
|
||||||
|
.zip(before.iter())
|
||||||
|
.all(|(a, b)| a.coordinates.lng == b.coordinates.lng)
|
||||||
|
);
|
||||||
|
// doing it again changes nothing
|
||||||
|
let again = InsertAnchor {
|
||||||
|
lng: point.coordinates.lng,
|
||||||
|
lat: point.coordinates.lat,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state());
|
||||||
|
assert!(again.is_ok() || again == Err(CommandError::NothingToDo));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nothing_to_do_without_a_segment() {
|
||||||
|
let (mut fx, _) = loaded("data/with_time.gpx");
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
assert_eq!(
|
||||||
|
InsertAnchor { lng: 1.0, lat: 1.0 }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,677 @@
|
|||||||
|
use std::{collections::HashSet, rc::Rc};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Apply, CommandError, FileId, GlobalStatistics, Selection, State, Statistics, Track, TrackId,
|
||||||
|
TrackSegment, TrackSegmentId, Waypoint, Waypoints, copy_track, replace_trackpoints,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum MergeType {
|
||||||
|
/// The trackpoints of the elements are connected in a single segment.
|
||||||
|
Connect,
|
||||||
|
/// The elements are put together, in the first one, which keeps them as they are.
|
||||||
|
Group,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges the selected elements into the first of them:
|
||||||
|
///
|
||||||
|
/// - files: the other files are deleted. The tracks of all of them are in the first file, as
|
||||||
|
/// they are with `Group`, or all connected in the first track with `Connect`. The waypoints of
|
||||||
|
/// all of them are in the first file too, except the ones that are the same. With
|
||||||
|
/// `keep_originals`, no file is deleted nor changed: the result is a new file (with the info of
|
||||||
|
/// the first one and new ids), which comes right after the last of the files and is selected.
|
||||||
|
/// - tracks of a file: the first track gets their segments, as they are with `Group`, or all
|
||||||
|
/// connected in one segment with `Connect`. The other tracks are removed.
|
||||||
|
/// - segments of a track: they are all connected in a segment at the position of the first one
|
||||||
|
/// with `Connect`. With `Group` they are put one after the other, at that position.
|
||||||
|
///
|
||||||
|
/// The segment made by `Connect` goes on in time, if there are timestamps, at the moving speed
|
||||||
|
/// of the selection, see [`replace_trackpoints`], which `remove_gaps` is given.
|
||||||
|
///
|
||||||
|
/// There is nothing to do when there is nothing to merge: a single file to group, a single track
|
||||||
|
/// that has no more than one segment, a single segment...
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Merge {
|
||||||
|
pub type_: MergeType,
|
||||||
|
pub remove_gaps: bool,
|
||||||
|
/// Only for files: tracks and segments are merged in place anyway.
|
||||||
|
pub keep_originals: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for Merge {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let selection = state.selection.clone();
|
||||||
|
match &selection {
|
||||||
|
Selection::File { file_ids } => self.merge_files(state, file_ids),
|
||||||
|
Selection::Track { file_id, trk_ids } => self.merge_tracks(state, *file_id, trk_ids),
|
||||||
|
Selection::TrackSegment {
|
||||||
|
file_id,
|
||||||
|
trk_id,
|
||||||
|
trkseg_ids,
|
||||||
|
} => self.merge_segments(state, *file_id, *trk_id, trkseg_ids),
|
||||||
|
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => {
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Merge {
|
||||||
|
fn merge_files(
|
||||||
|
&self,
|
||||||
|
state: &mut State,
|
||||||
|
file_ids: &HashSet<FileId>,
|
||||||
|
) -> Result<(), CommandError> {
|
||||||
|
// in the order of the files
|
||||||
|
let ordered: Vec<FileId> = state
|
||||||
|
.order
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.filter(|id| file_ids.contains(id))
|
||||||
|
.chain(file_ids.iter().filter(|id| !state.order.0.contains(id)))
|
||||||
|
.filter(|id| state.files.contains_key(id))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
let Some(&target_id) = ordered.first() else {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tracks: Vec<Track> = vec![];
|
||||||
|
let mut waypoints: Vec<Waypoint> = vec![];
|
||||||
|
for id in &ordered {
|
||||||
|
let file = &state.files[id];
|
||||||
|
tracks.extend(file.trk.iter().cloned());
|
||||||
|
for waypoint in file.wpt.iter() {
|
||||||
|
if !waypoints.iter().any(|kept| kept.same_content(waypoint)) {
|
||||||
|
waypoints.push(waypoint.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let segments: Vec<TrackSegment> = tracks
|
||||||
|
.iter()
|
||||||
|
.flat_map(|track| track.trkseg.iter().cloned())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match self.type_ {
|
||||||
|
MergeType::Group if ordered.len() == 1 => return Err(CommandError::NothingToDo),
|
||||||
|
MergeType::Connect if ordered.len() == 1 && segments.len() <= 1 => {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
MergeType::Connect if !segments.is_empty() => {
|
||||||
|
// a single track, which is the first one, with the connected segment
|
||||||
|
let merged = connect(&segments, segments[0].id, self.remove_gaps);
|
||||||
|
let first = Track {
|
||||||
|
trkseg: vec![merged],
|
||||||
|
..tracks[0].clone()
|
||||||
|
};
|
||||||
|
tracks = vec![first];
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut target = (*state.files[&target_id]).clone();
|
||||||
|
target.trk = tracks;
|
||||||
|
target.wpt = Waypoints::default();
|
||||||
|
target.wpt.insert_at(0, waypoints);
|
||||||
|
if self.keep_originals {
|
||||||
|
// a new file, whose tracks and segments do not share their ids with the originals
|
||||||
|
target.id = Default::default();
|
||||||
|
target.trk = target.trk.iter().map(copy_track).collect();
|
||||||
|
let at = state
|
||||||
|
.order
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.rposition(|id| file_ids.contains(id))
|
||||||
|
.map_or(state.order.0.len(), |i| i + 1);
|
||||||
|
state.order.0.insert(at, target.id);
|
||||||
|
*state.selection = Selection::File {
|
||||||
|
file_ids: [target.id].into(),
|
||||||
|
};
|
||||||
|
state.files.insert(target.id, Rc::new(target));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for id in &ordered[1..] {
|
||||||
|
state.files.remove(id);
|
||||||
|
}
|
||||||
|
state.order.0.retain(|id| !ordered[1..].contains(id));
|
||||||
|
state.files.insert(target_id, Rc::new(target));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_tracks(
|
||||||
|
&self,
|
||||||
|
state: &mut State,
|
||||||
|
file_id: FileId,
|
||||||
|
trk_ids: &HashSet<TrackId>,
|
||||||
|
) -> Result<(), CommandError> {
|
||||||
|
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||||
|
let selected: Vec<usize> = (0..file.trk.len())
|
||||||
|
.filter(|i| trk_ids.contains(&file.trk[*i].id))
|
||||||
|
.collect();
|
||||||
|
let Some(&first) = selected.first() else {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
};
|
||||||
|
let segments: Vec<TrackSegment> = selected
|
||||||
|
.iter()
|
||||||
|
.flat_map(|i| file.trk[*i].trkseg.iter().cloned())
|
||||||
|
.collect();
|
||||||
|
if selected.len() == 1 && (self.type_ == MergeType::Group || segments.len() <= 1) {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
|
||||||
|
let merged = match self.type_ {
|
||||||
|
MergeType::Group => segments,
|
||||||
|
MergeType::Connect if segments.is_empty() => vec![],
|
||||||
|
MergeType::Connect => {
|
||||||
|
vec![connect(&segments, segments[0].id, self.remove_gaps)]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut file = (**file).clone();
|
||||||
|
file.trk[first].trkseg = merged;
|
||||||
|
let mut index = 0;
|
||||||
|
file.trk.retain(|_| {
|
||||||
|
index += 1;
|
||||||
|
!selected[1..].contains(&(index - 1))
|
||||||
|
});
|
||||||
|
state.files.insert(file_id, Rc::new(file));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_segments(
|
||||||
|
&self,
|
||||||
|
state: &mut State,
|
||||||
|
file_id: FileId,
|
||||||
|
trk_id: TrackId,
|
||||||
|
ids: &HashSet<TrackSegmentId>,
|
||||||
|
) -> Result<(), CommandError> {
|
||||||
|
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||||
|
let trk = file
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.position(|trk| trk.id == trk_id)
|
||||||
|
.ok_or(CommandError::NothingToDo)?;
|
||||||
|
let segments = &file.trk[trk].trkseg;
|
||||||
|
let selected: Vec<usize> = (0..segments.len())
|
||||||
|
.filter(|i| ids.contains(&segments[*i].id))
|
||||||
|
.collect();
|
||||||
|
if selected.len() < 2 {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
let first = selected[0];
|
||||||
|
let chosen: Vec<TrackSegment> = selected.iter().map(|i| segments[*i].clone()).collect();
|
||||||
|
let merged = match self.type_ {
|
||||||
|
MergeType::Connect => vec![connect(&chosen, chosen[0].id, self.remove_gaps)],
|
||||||
|
MergeType::Group => {
|
||||||
|
if selected.iter().enumerate().all(|(i, s)| *s == first + i) {
|
||||||
|
// already one after the other
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
chosen
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut file = (**file).clone();
|
||||||
|
let mut remaining: Vec<TrackSegment> = file.trk[trk]
|
||||||
|
.trkseg
|
||||||
|
.iter()
|
||||||
|
.filter(|segment| !ids.contains(&segment.id))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
// the ones before the first selected one are all kept
|
||||||
|
remaining.splice(first..first, merged);
|
||||||
|
file.trk[trk].trkseg = remaining;
|
||||||
|
state.files.insert(file_id, Rc::new(file));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The segments connected in a single one, which has the id `id`: the trackpoints of the second
|
||||||
|
/// go after the ones of the first and so on.
|
||||||
|
///
|
||||||
|
/// If there are timestamps, the new segment goes on in time, see [`replace_trackpoints`]: the
|
||||||
|
/// trackpoints that have none get some, at the moving speed of the segments, and the ones that
|
||||||
|
/// are too early follow the previous ones. The time of the first trackpoint is the one it has, or
|
||||||
|
/// the one it would have if it was as far in time from the first timed one as its distance
|
||||||
|
/// says.
|
||||||
|
fn connect(segments: &[TrackSegment], id: TrackSegmentId, remove_gaps: bool) -> TrackSegment {
|
||||||
|
let mut global = GlobalStatistics::default();
|
||||||
|
let mut distance = 0.0;
|
||||||
|
// the first time, and the distance from the start at which it is
|
||||||
|
let mut first_timed: Option<(i64, f64)> = None;
|
||||||
|
for segment in segments {
|
||||||
|
let stats = Statistics::compute(segment);
|
||||||
|
if first_timed.is_none() {
|
||||||
|
first_timed = segment.iter().zip(&stats.local).find_map(|(point, local)| {
|
||||||
|
point
|
||||||
|
.time
|
||||||
|
.map(|time| (time, distance + local.total_distance))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
distance += stats.global.total_distance;
|
||||||
|
global.merge(&stats.global);
|
||||||
|
}
|
||||||
|
let speed = global.moving_speed().filter(|speed| *speed > 0.0);
|
||||||
|
let start_time = speed
|
||||||
|
.and_then(|speed| first_timed.map(|(time, km)| time - (3_600_000.0 * km / speed) as i64));
|
||||||
|
|
||||||
|
let mut points = vec![];
|
||||||
|
for segment in segments {
|
||||||
|
let end = points.len();
|
||||||
|
replace_trackpoints(
|
||||||
|
&mut points,
|
||||||
|
end,
|
||||||
|
end,
|
||||||
|
segment.iter().cloned().collect(),
|
||||||
|
speed,
|
||||||
|
start_time,
|
||||||
|
remove_gaps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut merged = TrackSegment::default();
|
||||||
|
merged.splice(0, 0, points);
|
||||||
|
merged.id = id;
|
||||||
|
merged
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::{File, Load, engine::command::fixture::Fixture};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn load(fx: &mut Fixture, path: &str) -> FileId {
|
||||||
|
let data = std::fs::read(path).unwrap();
|
||||||
|
Load {
|
||||||
|
data: &data,
|
||||||
|
name: "file",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
*fx.order.0.last().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge(type_: MergeType) -> Merge {
|
||||||
|
Merge {
|
||||||
|
type_,
|
||||||
|
remove_gaps: false,
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_files(fx: &mut Fixture, ids: &[FileId]) {
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: ids.iter().copied().collect(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn points(file: &File) -> usize {
|
||||||
|
file.trk
|
||||||
|
.iter()
|
||||||
|
.flat_map(|t| &t.trkseg)
|
||||||
|
.map(|s| s.len())
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn waypoint(name: &str) -> Waypoint {
|
||||||
|
Waypoint {
|
||||||
|
name: Some(name.into()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_group_files() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||||
|
let b = load(&mut fx, "data/simple.gpx");
|
||||||
|
let mut file_a = (*fx.files[&a]).clone();
|
||||||
|
file_a
|
||||||
|
.wpt
|
||||||
|
.insert_at(0, vec![waypoint("x"), waypoint("same")]);
|
||||||
|
fx.files.insert(a, Rc::new(file_a));
|
||||||
|
let mut file_b = (*fx.files[&b]).clone();
|
||||||
|
file_b
|
||||||
|
.wpt
|
||||||
|
.insert_at(0, vec![waypoint("same"), waypoint("y")]);
|
||||||
|
fx.files.insert(b, Rc::new(file_b));
|
||||||
|
let (before_a, before_b) = (fx.files[&a].clone(), fx.files[&b].clone());
|
||||||
|
select_files(&mut fx, &[b, a]);
|
||||||
|
|
||||||
|
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
|
||||||
|
|
||||||
|
// the first file (in the order of the files) gets everything, the other one goes
|
||||||
|
assert_eq!(fx.order.0, [a]);
|
||||||
|
assert!(!fx.files.contains_key(&b));
|
||||||
|
let merged = &fx.files[&a];
|
||||||
|
assert_eq!(merged.trk.len(), before_a.trk.len() + before_b.trk.len());
|
||||||
|
assert_eq!(merged.trk[0].id, before_a.trk[0].id);
|
||||||
|
assert_eq!(
|
||||||
|
merged.trk.last().unwrap().id,
|
||||||
|
before_b.trk.last().unwrap().id
|
||||||
|
);
|
||||||
|
assert_eq!(points(merged), points(&before_a) + points(&before_b));
|
||||||
|
// the waypoints that are the same are only once
|
||||||
|
let names: Vec<_> = merged.wpt.iter().map(|w| w.name.clone().unwrap()).collect();
|
||||||
|
assert_eq!(names, ["x", "same", "y"]);
|
||||||
|
assert_eq!(merged.info, before_a.info);
|
||||||
|
// a single file has nothing to group
|
||||||
|
select_files(&mut fx, &[a]);
|
||||||
|
assert_eq!(
|
||||||
|
merge(MergeType::Group).apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_files_keeping_the_originals() {
|
||||||
|
for type_ in [MergeType::Group, MergeType::Connect] {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||||
|
let b = load(&mut fx, "data/simple.gpx");
|
||||||
|
let c = load(&mut fx, "data/simple.gpx");
|
||||||
|
let (before_a, before_b) = (fx.files[&a].clone(), fx.files[&b].clone());
|
||||||
|
select_files(&mut fx, &[a, b]);
|
||||||
|
|
||||||
|
Merge {
|
||||||
|
type_,
|
||||||
|
remove_gaps: false,
|
||||||
|
keep_originals: true,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// the files are untouched, the new one comes right after the last of them
|
||||||
|
assert!(Rc::ptr_eq(&fx.files[&a], &before_a));
|
||||||
|
assert!(Rc::ptr_eq(&fx.files[&b], &before_b));
|
||||||
|
assert_eq!(fx.files.len(), 4);
|
||||||
|
assert_eq!(&fx.order.0[..2], [a, b]);
|
||||||
|
assert_eq!(fx.order.0[3], c);
|
||||||
|
let merged_id = fx.order.0[2];
|
||||||
|
let merged = &fx.files[&merged_id];
|
||||||
|
assert_eq!(points(merged), points(&before_a) + points(&before_b));
|
||||||
|
assert_eq!(merged.info, before_a.info);
|
||||||
|
// new ids for the tracks and segments
|
||||||
|
let ids = |f: &File| -> Vec<_> {
|
||||||
|
f.trk
|
||||||
|
.iter()
|
||||||
|
.flat_map(|t| std::iter::once(t.id.0).chain(t.trkseg.iter().map(|s| s.id.0)))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let originals: Vec<_> = ids(&before_a).into_iter().chain(ids(&before_b)).collect();
|
||||||
|
assert!(ids(merged).iter().all(|id| !originals.contains(id)));
|
||||||
|
// and it is the selection
|
||||||
|
assert!(matches!(&fx.selection, Selection::File { file_ids }
|
||||||
|
if *file_ids == HashSet::from([merged_id])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keeping_the_originals_does_not_matter_when_merging_tracks() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||||
|
let tracks: HashSet<_> = fx.files[&a].trk.iter().map(|t| t.id).collect();
|
||||||
|
let before = fx.files[&a].trk.len();
|
||||||
|
assert!(before > 1);
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: a,
|
||||||
|
trk_ids: tracks,
|
||||||
|
};
|
||||||
|
|
||||||
|
Merge {
|
||||||
|
type_: MergeType::Group,
|
||||||
|
remove_gaps: false,
|
||||||
|
keep_originals: true,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fx.files.len(), 1);
|
||||||
|
assert_eq!(fx.files[&a].trk.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connect_files() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||||
|
let b = load(&mut fx, "data/simple.gpx");
|
||||||
|
let (before_a, before_b) = (fx.files[&a].clone(), fx.files[&b].clone());
|
||||||
|
select_files(&mut fx, &[a, b]);
|
||||||
|
|
||||||
|
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||||
|
|
||||||
|
let merged = &fx.files[&a];
|
||||||
|
assert_eq!(fx.order.0, [a]);
|
||||||
|
// a single track, with a single segment that has all the trackpoints in order
|
||||||
|
assert_eq!(merged.trk.len(), 1);
|
||||||
|
assert_eq!(merged.trk[0].id, before_a.trk[0].id);
|
||||||
|
assert_eq!(merged.trk[0].trkseg.len(), 1);
|
||||||
|
let segment = &merged.trk[0].trkseg[0];
|
||||||
|
assert_eq!(segment.len(), points(&before_a) + points(&before_b));
|
||||||
|
let expected: Vec<f64> = before_a
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.chain(&before_b.trk)
|
||||||
|
.flat_map(|t| &t.trkseg)
|
||||||
|
.flat_map(|s| s.iter().map(|p| p.coordinates.lng))
|
||||||
|
.collect();
|
||||||
|
let lngs: Vec<f64> = segment.iter().map(|p| p.coordinates.lng).collect();
|
||||||
|
assert_eq!(lngs, expected);
|
||||||
|
assert_eq!(segment.id, before_a.trk[0].trkseg[0].id);
|
||||||
|
assert_eq!(segment[0].anchor, Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connect_goes_on_in_time() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
// the same times twice: the second part would be too early
|
||||||
|
let a = load(&mut fx, "data/with_time.gpx");
|
||||||
|
let b = load(&mut fx, "data/with_time.gpx");
|
||||||
|
select_files(&mut fx, &[a, b]);
|
||||||
|
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||||
|
let segment = &fx.files[&a].trk[0].trkseg[0];
|
||||||
|
let times: Vec<i64> = segment.iter().map(|p| p.time.unwrap()).collect();
|
||||||
|
assert_eq!(times.len(), 160);
|
||||||
|
assert!(times.windows(2).all(|w| w[0] <= w[1]), "{times:?}");
|
||||||
|
assert!(times[80] > times[79]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connect_without_gaps_brings_the_parts_closer() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let a = load(&mut fx, "data/with_time.gpx");
|
||||||
|
// the second file comes a day later
|
||||||
|
let b = {
|
||||||
|
let data = std::fs::read("data/with_time.gpx").unwrap();
|
||||||
|
Load {
|
||||||
|
data: &data,
|
||||||
|
name: "late",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
*fx.order.0.last().unwrap()
|
||||||
|
};
|
||||||
|
let mut file = (*fx.files[&b]).clone();
|
||||||
|
for track in file.trk.iter_mut() {
|
||||||
|
for segment in track.trkseg.iter_mut() {
|
||||||
|
let points: Vec<_> = segment
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
let mut p = p.clone();
|
||||||
|
p.time = p.time.map(|t| t + 86_400_000);
|
||||||
|
p
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let len = segment.len();
|
||||||
|
segment.splice(0, len, points);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fx.files.insert(b, Rc::new(file));
|
||||||
|
let gap = |fx: &Fixture, remove_gaps: bool| {
|
||||||
|
let mut fx2 = Fixture {
|
||||||
|
files: fx.files.clone(),
|
||||||
|
order: crate::FileOrder(fx.order.0.clone()),
|
||||||
|
selection: Selection::File {
|
||||||
|
file_ids: [a, b].into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
Merge {
|
||||||
|
type_: MergeType::Connect,
|
||||||
|
remove_gaps,
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx2.state())
|
||||||
|
.unwrap();
|
||||||
|
let segment = &fx2.files[&a].trk[0].trkseg[0];
|
||||||
|
segment[80].time.unwrap() - segment[79].time.unwrap()
|
||||||
|
};
|
||||||
|
let with_gap = gap(&fx, false);
|
||||||
|
let without = gap(&fx, true);
|
||||||
|
assert!(with_gap > 80_000_000, "{with_gap}");
|
||||||
|
assert!(without < 60_000, "{without}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_tracks() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||||
|
let file = fx.files[&id].clone();
|
||||||
|
assert!(file.trk.len() >= 2);
|
||||||
|
let ids: HashSet<TrackId> = file.trk.iter().map(|t| t.id).collect();
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: ids,
|
||||||
|
};
|
||||||
|
let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
|
||||||
|
|
||||||
|
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
|
||||||
|
let merged = &fx.files[&id];
|
||||||
|
assert_eq!(merged.trk.len(), 1);
|
||||||
|
assert_eq!(merged.trk[0].id, file.trk[0].id);
|
||||||
|
assert_eq!(merged.trk[0].trkseg.len(), segments);
|
||||||
|
assert_eq!(points(merged), points(&file));
|
||||||
|
|
||||||
|
// connected: a single segment
|
||||||
|
fx.files.insert(id, file.clone());
|
||||||
|
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||||
|
let merged = &fx.files[&id];
|
||||||
|
assert_eq!(merged.trk.len(), 1);
|
||||||
|
assert_eq!(merged.trk[0].trkseg.len(), 1);
|
||||||
|
assert_eq!(merged.trk[0].trkseg[0].len(), points(&file));
|
||||||
|
|
||||||
|
// one track with a single segment: nothing to do
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: [file.trk[0].id].into(),
|
||||||
|
};
|
||||||
|
for type_ in [MergeType::Group, MergeType::Connect] {
|
||||||
|
assert_eq!(
|
||||||
|
merge(type_).apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connect_the_segments_of_a_track() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||||
|
let file = fx.files[&id].clone();
|
||||||
|
let (index, track) = file
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(_, t)| t.trkseg.len() >= 2)
|
||||||
|
.unwrap();
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: [track.id].into(),
|
||||||
|
};
|
||||||
|
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||||
|
let merged = &fx.files[&id].trk[index];
|
||||||
|
assert_eq!(merged.id, track.id);
|
||||||
|
assert_eq!(merged.trkseg.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
merged.trkseg[0].len(),
|
||||||
|
track.trkseg.iter().map(|s| s.len()).sum::<usize>()
|
||||||
|
);
|
||||||
|
// the other tracks are untouched
|
||||||
|
assert_eq!(fx.files[&id].trk.len(), file.trk.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_segments() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||||
|
// a track with three segments
|
||||||
|
let mut three = (*fx.files[&id]).clone();
|
||||||
|
let base = three.trk[0].trkseg[0].clone();
|
||||||
|
three.trk[0].trkseg = (0..3).map(|_| crate::copy_segment(&base)).collect();
|
||||||
|
fx.files.insert(id, Rc::new(three));
|
||||||
|
let file = fx.files[&id].clone();
|
||||||
|
let (index, track) = (0, &file.trk[0]);
|
||||||
|
let segments = &track.trkseg;
|
||||||
|
// the first and the last ones
|
||||||
|
let (first, last) = (&segments[0], &segments[2]);
|
||||||
|
fx.selection = Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: track.id,
|
||||||
|
trkseg_ids: [first.id, last.id].into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// grouped: next to each other, at the place of the first one
|
||||||
|
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
|
||||||
|
let ids: Vec<_> = fx.files[&id].trk[index]
|
||||||
|
.trkseg
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.id)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(ids[..3], [first.id, last.id, segments[1].id]);
|
||||||
|
|
||||||
|
// connected: one segment, with the id of the first one, at its place
|
||||||
|
fx.files.insert(id, file.clone());
|
||||||
|
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||||
|
let merged = &fx.files[&id].trk[index].trkseg;
|
||||||
|
assert_eq!(merged.len(), segments.len() - 1);
|
||||||
|
assert_eq!(merged[0].id, first.id);
|
||||||
|
assert_eq!(merged[0].len(), first.len() + last.len());
|
||||||
|
assert_eq!(merged[1].id, segments[1].id);
|
||||||
|
|
||||||
|
// a single segment, or segments that are already together: nothing to do
|
||||||
|
fx.selection = Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: track.id,
|
||||||
|
trkseg_ids: [first.id].into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
merge(MergeType::Connect).apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
fx.files.insert(id, file.clone());
|
||||||
|
fx.selection = Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: track.id,
|
||||||
|
trkseg_ids: [first.id, segments[1].id].into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
merge(MergeType::Group).apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nothing_to_merge() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let id = load(&mut fx, "data/simple.gpx");
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
for selection in [Selection::Empty, Selection::Waypoints { file_id: id }] {
|
||||||
|
fx.selection = selection;
|
||||||
|
assert_eq!(
|
||||||
|
merge(MergeType::Connect).apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
mod change_loop_start;
|
||||||
|
mod clean;
|
||||||
|
mod crop;
|
||||||
|
mod edit_waypoint;
|
||||||
|
mod elevation;
|
||||||
|
mod extract;
|
||||||
|
mod insert_anchor;
|
||||||
|
mod merge;
|
||||||
|
mod move_waypoint;
|
||||||
|
mod new_waypoint;
|
||||||
|
mod reduce;
|
||||||
|
mod reverse;
|
||||||
|
mod round_trip;
|
||||||
|
mod route;
|
||||||
|
mod split;
|
||||||
|
mod time;
|
||||||
|
|
||||||
|
pub use change_loop_start::*;
|
||||||
|
pub use clean::*;
|
||||||
|
pub use crop::*;
|
||||||
|
pub use edit_waypoint::*;
|
||||||
|
pub use elevation::*;
|
||||||
|
pub use extract::*;
|
||||||
|
pub use insert_anchor::*;
|
||||||
|
pub use merge::*;
|
||||||
|
pub use move_waypoint::*;
|
||||||
|
pub use new_waypoint::*;
|
||||||
|
pub use reduce::*;
|
||||||
|
pub use reverse::*;
|
||||||
|
pub use round_trip::*;
|
||||||
|
pub use route::*;
|
||||||
|
pub use split::*;
|
||||||
|
pub use time::*;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
use crate::{Apply, CommandError, FileId, LngLat, State, WaypointId, update_waypoint};
|
||||||
|
|
||||||
|
/// Moves a waypoint of a file, whatever is selected (what a drag does): only its position and
|
||||||
|
/// elevation change.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MoveWaypoint {
|
||||||
|
pub file_id: FileId,
|
||||||
|
pub waypoint_id: WaypointId,
|
||||||
|
pub lng: f64,
|
||||||
|
pub lat: f64,
|
||||||
|
pub ele: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for MoveWaypoint {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
update_waypoint(state, self.file_id, self.waypoint_id, |wpt| {
|
||||||
|
wpt.coordinates = LngLat {
|
||||||
|
lng: self.lng,
|
||||||
|
lat: self.lat,
|
||||||
|
};
|
||||||
|
wpt.ele = self.ele;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
File, Selection, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixture() -> (Fixture, crate::FileId, Vec<crate::WaypointId>) {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let mut file = File::default();
|
||||||
|
file.wpt.push(WaypointChunk {
|
||||||
|
wpt: (0..3)
|
||||||
|
.map(|i| Waypoint {
|
||||||
|
name: Some(format!("w{i}")),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let id = file.id;
|
||||||
|
let ids = waypoint_ids(&file).collect();
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
fx.order.0.push(id);
|
||||||
|
(fx, id, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_only_the_given_waypoint_moves_whatever_is_selected() {
|
||||||
|
let (mut fx, id, ids) = fixture();
|
||||||
|
let rev = fx.files[&id].wpt.rev_id;
|
||||||
|
|
||||||
|
MoveWaypoint {
|
||||||
|
file_id: id,
|
||||||
|
waypoint_id: ids[1],
|
||||||
|
lng: 4.0,
|
||||||
|
lat: 50.0,
|
||||||
|
ele: 42.0,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let wpts: Vec<_> = fx.files[&id].wpt.iter().collect();
|
||||||
|
assert_eq!(wpts.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
wpts[1].coordinates.lng,
|
||||||
|
wpts[1].coordinates.lat,
|
||||||
|
wpts[1].ele
|
||||||
|
),
|
||||||
|
(4.0, 50.0, 42.0)
|
||||||
|
);
|
||||||
|
// the other fields and the other waypoints are as they were
|
||||||
|
assert_eq!(wpts[1].name.as_deref(), Some("w1"));
|
||||||
|
assert_eq!(wpts[1].id, ids[1]);
|
||||||
|
assert_eq!((wpts[0].coordinates.lng, wpts[0].ele), (0.0, 0.0));
|
||||||
|
assert_eq!((wpts[2].coordinates.lat, wpts[2].ele), (0.0, 0.0));
|
||||||
|
// the coordinates changed, which is noticed by what is derived from them
|
||||||
|
assert_ne!(fx.files[&id].wpt.rev_id, rev);
|
||||||
|
assert_eq!(fx.selection, Selection::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unknown_waypoint_or_file_is_nothing_to_do() {
|
||||||
|
let (mut fx, id, ids) = fixture();
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
for (file_id, waypoint_id) in [(id, WaypointId::default()), (FileId::default(), ids[0])] {
|
||||||
|
let result = MoveWaypoint {
|
||||||
|
file_id,
|
||||||
|
waypoint_id,
|
||||||
|
lng: 1.0,
|
||||||
|
lat: 1.0,
|
||||||
|
ele: 1.0,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state());
|
||||||
|
assert!(matches!(result, Err(CommandError::NothingToDo)));
|
||||||
|
}
|
||||||
|
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{Apply, CommandError, File, FileId, Link, LngLat, Selection, State, Waypoint};
|
||||||
|
|
||||||
|
/// Adds a waypoint at the end of the waypoints of each selected file (or of the file of the
|
||||||
|
/// selected elements). The strings are empty when the waypoint does not have the field.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct NewWaypoint<'a> {
|
||||||
|
pub lng: f64,
|
||||||
|
pub lat: f64,
|
||||||
|
pub ele: f64,
|
||||||
|
pub name: &'a str,
|
||||||
|
pub desc: &'a str,
|
||||||
|
pub icon: &'a str,
|
||||||
|
pub link: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty(text: &str) -> Option<String> {
|
||||||
|
(!text.is_empty()).then(|| text.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the fields of a waypoint: the strings that are empty remove the field.
|
||||||
|
pub fn set_waypoint_fields(
|
||||||
|
waypoint: &mut Waypoint,
|
||||||
|
position: (f64, f64, f64),
|
||||||
|
name: &str,
|
||||||
|
desc: &str,
|
||||||
|
icon: &str,
|
||||||
|
link: &str,
|
||||||
|
) {
|
||||||
|
let (lng, lat, ele) = position;
|
||||||
|
waypoint.coordinates = LngLat { lng, lat };
|
||||||
|
waypoint.ele = ele;
|
||||||
|
waypoint.name = non_empty(name);
|
||||||
|
waypoint.desc = non_empty(desc);
|
||||||
|
// the description is also the comment, as it is what the other applications show
|
||||||
|
waypoint.cmt = non_empty(desc);
|
||||||
|
waypoint.sym = non_empty(icon);
|
||||||
|
// the form has a single link: it is the first one, the others are left as they are
|
||||||
|
match (non_empty(link), waypoint.links.first_mut()) {
|
||||||
|
(Some(href), Some(first)) if first.href != href => *first = Link { href, text: None },
|
||||||
|
(Some(href), None) => waypoint.links.push(Link { href, text: None }),
|
||||||
|
(None, Some(_)) => {
|
||||||
|
waypoint.links.remove(0);
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for NewWaypoint<'_> {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let file_ids: Vec<FileId> = match &*state.selection {
|
||||||
|
Selection::Empty => vec![],
|
||||||
|
Selection::File { file_ids } => state
|
||||||
|
.order
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.filter(|id| file_ids.contains(id))
|
||||||
|
.copied()
|
||||||
|
.collect(),
|
||||||
|
Selection::Track { file_id, .. }
|
||||||
|
| Selection::TrackSegment { file_id, .. }
|
||||||
|
| Selection::Waypoints { file_id }
|
||||||
|
| Selection::Waypoint { file_id, .. } => vec![*file_id],
|
||||||
|
};
|
||||||
|
let file_ids: Vec<FileId> = file_ids
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| state.files.contains_key(id))
|
||||||
|
.collect();
|
||||||
|
if file_ids.is_empty() {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
|
||||||
|
for id in file_ids {
|
||||||
|
let mut waypoint = Waypoint::default();
|
||||||
|
set_waypoint_fields(
|
||||||
|
&mut waypoint,
|
||||||
|
(self.lng, self.lat, self.ele),
|
||||||
|
self.name,
|
||||||
|
self.desc,
|
||||||
|
self.icon,
|
||||||
|
self.link,
|
||||||
|
);
|
||||||
|
let file: &mut File = Rc::make_mut(state.files.get_mut(&id).unwrap());
|
||||||
|
file.wpt.insert_at(usize::MAX, vec![waypoint]);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::{New, TrackId, TrackSegmentId, engine::command::fixture::Fixture, waypoint_ids};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn new_waypoint(name: &str) -> NewWaypoint<'_> {
|
||||||
|
NewWaypoint {
|
||||||
|
lng: 4.5,
|
||||||
|
lat: 50.25,
|
||||||
|
ele: 123.0,
|
||||||
|
name,
|
||||||
|
desc: "a description",
|
||||||
|
icon: "Summit",
|
||||||
|
link: "https://example.com",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn waypoints(fx: &Fixture, id: FileId) -> Vec<Waypoint> {
|
||||||
|
fx.files[&id].wpt.iter().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_waypoint_in_the_selected_file() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
New {
|
||||||
|
name: "a",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
New {
|
||||||
|
name: "b",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let (a, b) = (fx.order.0[0], fx.order.0[1]);
|
||||||
|
let rev = fx.files[&b].wpt.rev_id;
|
||||||
|
|
||||||
|
// b is selected
|
||||||
|
new_waypoint("peak").apply(&mut fx.state()).unwrap();
|
||||||
|
assert!(waypoints(&fx, a).is_empty());
|
||||||
|
let created = waypoints(&fx, b);
|
||||||
|
assert_eq!(created.len(), 1);
|
||||||
|
let wpt = &created[0];
|
||||||
|
assert_eq!(
|
||||||
|
(wpt.coordinates.lng, wpt.coordinates.lat, wpt.ele),
|
||||||
|
(4.5, 50.25, 123.0)
|
||||||
|
);
|
||||||
|
assert_eq!(wpt.name.as_deref(), Some("peak"));
|
||||||
|
assert_eq!(wpt.desc.as_deref(), Some("a description"));
|
||||||
|
assert_eq!(wpt.cmt.as_deref(), Some("a description"));
|
||||||
|
assert_eq!(wpt.sym.as_deref(), Some("Summit"));
|
||||||
|
assert_eq!(
|
||||||
|
wpt.links.first().map(|l| l.href.as_str()),
|
||||||
|
Some("https://example.com")
|
||||||
|
);
|
||||||
|
assert_ne!(fx.files[&b].wpt.rev_id, rev);
|
||||||
|
|
||||||
|
// added after the others
|
||||||
|
let mut second = new_waypoint("second");
|
||||||
|
second.lng = 5.0;
|
||||||
|
second.apply(&mut fx.state()).unwrap();
|
||||||
|
let names: Vec<_> = waypoints(&fx, b)
|
||||||
|
.iter()
|
||||||
|
.map(|w| w.name.clone().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(names, ["peak", "second"]);
|
||||||
|
// and not selected
|
||||||
|
assert_eq!(fx.selected_files(), [b].into());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_fields_are_not_set() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
New {
|
||||||
|
name: "a",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let a = fx.order.0[0];
|
||||||
|
NewWaypoint {
|
||||||
|
lng: 1.0,
|
||||||
|
lat: 2.0,
|
||||||
|
ele: 0.0,
|
||||||
|
name: "",
|
||||||
|
desc: "",
|
||||||
|
icon: "",
|
||||||
|
link: "",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let wpt = &waypoints(&fx, a)[0];
|
||||||
|
assert!(wpt.name.is_none() && wpt.desc.is_none() && wpt.cmt.is_none());
|
||||||
|
assert!(wpt.sym.is_none() && wpt.links.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_each_selected_file_gets_its_own_waypoint() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
for name in ["a", "b", "c"] {
|
||||||
|
New {
|
||||||
|
name,
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let ids = fx.order.0.clone();
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([ids[0], ids[2]]),
|
||||||
|
};
|
||||||
|
new_waypoint("w").apply(&mut fx.state()).unwrap();
|
||||||
|
assert_eq!(waypoints(&fx, ids[0]).len(), 1);
|
||||||
|
assert!(waypoints(&fx, ids[1]).is_empty());
|
||||||
|
assert_eq!(waypoints(&fx, ids[2]).len(), 1);
|
||||||
|
// not the same waypoint
|
||||||
|
assert_ne!(waypoints(&fx, ids[0])[0].id, waypoints(&fx, ids[2])[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_file_of_the_selected_elements_is_used() {
|
||||||
|
for selection in [
|
||||||
|
|id| Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: [TrackId::default()].into(),
|
||||||
|
},
|
||||||
|
|id| Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: TrackId::default(),
|
||||||
|
trkseg_ids: [TrackSegmentId::default()].into(),
|
||||||
|
},
|
||||||
|
|id| Selection::Waypoints { file_id: id },
|
||||||
|
|id| Selection::Waypoint {
|
||||||
|
file_id: id,
|
||||||
|
wpt_ids: HashSet::new(),
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
New {
|
||||||
|
name: "a",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let a = fx.order.0[0];
|
||||||
|
fx.selection = selection(a);
|
||||||
|
new_waypoint("w").apply(&mut fx.state()).unwrap();
|
||||||
|
assert_eq!(waypoint_ids(&fx.files[&a]).count(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nothing_selected_or_unknown_file() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
New {
|
||||||
|
name: "a",
|
||||||
|
trackpoint: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
assert_eq!(
|
||||||
|
new_waypoint("w").apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
fx.selection = Selection::Waypoints {
|
||||||
|
file_id: FileId::default(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
new_waypoint("w").apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
assert_eq!(fx.files.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
use crate::{Apply, CommandError, State, reduce_indices, update_segments};
|
||||||
|
|
||||||
|
/// Removes the trackpoints of the selected segments that are less than `tolerance` meters away
|
||||||
|
/// from the line of their neighbours (Ramer-Douglas-Peucker, see [`reduce_indices`]). The ends of the segments are
|
||||||
|
/// kept.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Reduce {
|
||||||
|
pub tolerance: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for Reduce {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
if !(self.tolerance.is_finite() && self.tolerance > 0.0) {
|
||||||
|
return Err(CommandError::InvalidData(format!(
|
||||||
|
"tolerance {} should be positive",
|
||||||
|
self.tolerance
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let locations = state
|
||||||
|
.selection
|
||||||
|
.segment_locations(state.files, &state.order.0);
|
||||||
|
let mut changed = vec![];
|
||||||
|
let mut kept_points = vec![];
|
||||||
|
for location in &locations {
|
||||||
|
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
|
||||||
|
let kept = reduce_indices(segment, self.tolerance);
|
||||||
|
if kept.len() == segment.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
changed.push(*location);
|
||||||
|
kept_points.push(kept.iter().map(|&i| segment[i].clone()).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
if changed.is_empty() {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
|
||||||
|
update_segments(state, &changed, |i, segment| {
|
||||||
|
let len = segment.len();
|
||||||
|
segment.splice(0, len, std::mem::take(&mut kept_points[i]));
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture, reduction_distances};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn loaded(path: &str) -> (Fixture, FileId) {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let data = std::fs::read(path).unwrap();
|
||||||
|
Load {
|
||||||
|
data: &data,
|
||||||
|
name: "file",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let id = fx.order.0[0];
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
};
|
||||||
|
(fx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(fx: &Fixture, id: FileId) -> usize {
|
||||||
|
fx.files[&id]
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.flat_map(|trk| &trk.trkseg)
|
||||||
|
.map(|seg| seg.len())
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_larger_tolerance_keeps_fewer_points_and_the_ends() {
|
||||||
|
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||||
|
let before = count(&fx, id);
|
||||||
|
let first = fx.files[&id].trk[0].trkseg[0][0].coordinates;
|
||||||
|
|
||||||
|
Reduce { tolerance: 5.0 }.apply(&mut fx.state()).unwrap();
|
||||||
|
let medium = count(&fx, id);
|
||||||
|
assert!(medium < before);
|
||||||
|
assert_eq!(fx.files[&id].trk[0].trkseg[0][0].coordinates.lng, first.lng);
|
||||||
|
|
||||||
|
Reduce { tolerance: 500.0 }.apply(&mut fx.state()).unwrap();
|
||||||
|
assert!(count(&fx, id) < medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_distances_tell_which_points_are_kept() {
|
||||||
|
for path in ["data/with_time.gpx", "data/with_tracks_and_segments.gpx"] {
|
||||||
|
let (fx, id) = loaded(path);
|
||||||
|
for seg in fx.files[&id].trk.iter().flat_map(|trk| &trk.trkseg) {
|
||||||
|
let distances = reduction_distances(seg);
|
||||||
|
for tolerance in [0.1, 0.5, 1.0, 3.0, 10.0, 30.0, 100.0, 1000.0] {
|
||||||
|
let from_distances: Vec<usize> = (0..seg.len())
|
||||||
|
.filter(|&i| distances[i] > tolerance)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
from_distances,
|
||||||
|
reduce_indices(seg, tolerance),
|
||||||
|
"{path} {tolerance}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_only_the_selected_segments_are_reduced() {
|
||||||
|
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||||
|
let others: usize = fx.files[&id]
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.flat_map(|t| &t.trkseg)
|
||||||
|
.skip(1)
|
||||||
|
.map(|s| s.len())
|
||||||
|
.sum();
|
||||||
|
let expected = reduce_indices(&fx.files[&id].trk[0].trkseg[0], 5.0).len();
|
||||||
|
fx.selection = {
|
||||||
|
let track = &fx.files[&id].trk[0];
|
||||||
|
Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: track.id,
|
||||||
|
trkseg_ids: HashSet::from([track.trkseg[0].id]),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Reduce { tolerance: 5.0 }.apply(&mut fx.state()).unwrap();
|
||||||
|
assert_eq!(count(&fx, id), expected + others);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_tolerance_and_nothing_to_remove() {
|
||||||
|
let (mut fx, _) = loaded("data/simple.gpx");
|
||||||
|
assert!(matches!(
|
||||||
|
Reduce { tolerance: 0.0 }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::InvalidData(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
Reduce { tolerance: 1e-9 }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Apply, CommandError, File, FileId, Selection, State, TrackId, TrackSegmentId, end_time,
|
||||||
|
reverse_segment, reverse_segments, reverse_tracks, start_time, track_end_time,
|
||||||
|
track_start_time,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Reverses the direction of the selected elements: the whole of each selected file (its tracks,
|
||||||
|
/// the segments of its tracks and their trackpoints are in the opposite order), of each selected
|
||||||
|
/// track (its segments and their trackpoints), or each selected segment on its own (its
|
||||||
|
/// trackpoints). The timestamps are mirrored, see [`reverse_segment`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Reverse;
|
||||||
|
|
||||||
|
impl Apply for Reverse {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let locations = state
|
||||||
|
.selection
|
||||||
|
.segment_locations(state.files, &state.order.0);
|
||||||
|
let points: usize = locations
|
||||||
|
.iter()
|
||||||
|
.map(|location| {
|
||||||
|
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
if points <= 1 {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut files: Vec<(FileId, File)> = vec![];
|
||||||
|
let mut edit = |file_id: &FileId, f: &dyn Fn(&mut File)| {
|
||||||
|
if let Some(file) = state.files.get(file_id) {
|
||||||
|
let mut file = (**file).clone();
|
||||||
|
f(&mut file);
|
||||||
|
files.push((*file_id, file));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match &*state.selection {
|
||||||
|
Selection::File { file_ids } => {
|
||||||
|
for file_id in file_ids {
|
||||||
|
edit(file_id, &|file| {
|
||||||
|
let end = file.trk.last().and_then(track_end_time);
|
||||||
|
let start = file.trk.first().and_then(track_start_time);
|
||||||
|
reverse_tracks(&mut file.trk, end, start);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Selection::Track { file_id, trk_ids } => edit(file_id, &|file| {
|
||||||
|
reverse_selected_tracks(file, trk_ids);
|
||||||
|
}),
|
||||||
|
Selection::TrackSegment {
|
||||||
|
file_id,
|
||||||
|
trk_id,
|
||||||
|
trkseg_ids,
|
||||||
|
} => edit(file_id, &|file| {
|
||||||
|
reverse_selected_segments(file, *trk_id, trkseg_ids);
|
||||||
|
}),
|
||||||
|
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (file_id, file) in files {
|
||||||
|
state.files.insert(file_id, Rc::new(file));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reverse_selected_tracks(file: &mut File, ids: &std::collections::HashSet<TrackId>) {
|
||||||
|
for track in file.trk.iter_mut().filter(|track| ids.contains(&track.id)) {
|
||||||
|
let end = track.trkseg.last().and_then(end_time);
|
||||||
|
let start = track.trkseg.first().and_then(start_time);
|
||||||
|
reverse_segments(&mut track.trkseg, end, start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reverse_selected_segments(
|
||||||
|
file: &mut File,
|
||||||
|
track_id: TrackId,
|
||||||
|
ids: &std::collections::HashSet<TrackSegmentId>,
|
||||||
|
) {
|
||||||
|
for track in file.trk.iter_mut().filter(|track| track.id == track_id) {
|
||||||
|
for segment in track
|
||||||
|
.trkseg
|
||||||
|
.iter_mut()
|
||||||
|
.filter(|segment| ids.contains(&segment.id))
|
||||||
|
{
|
||||||
|
let (end, start) = (end_time(segment), start_time(segment));
|
||||||
|
reverse_segment(segment, end, start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::{Load, TrackSegment, engine::command::fixture::Fixture};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn loaded(path: &str) -> (Fixture, FileId) {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let data = std::fs::read(path).unwrap();
|
||||||
|
Load {
|
||||||
|
data: &data,
|
||||||
|
name: "file",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let id = fx.order.0[0];
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
};
|
||||||
|
(fx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_segments(fx: &Fixture, id: FileId) -> Vec<TrackSegment> {
|
||||||
|
fx.files[&id]
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.flat_map(|track| track.trkseg.iter().cloned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lngs(segment: &TrackSegment) -> Vec<f64> {
|
||||||
|
segment.iter().map(|p| p.coordinates.lng).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reversing_a_file_reverses_tracks_segments_and_points() {
|
||||||
|
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||||
|
let before = all_segments(&fx, id);
|
||||||
|
assert!(before.len() >= 3);
|
||||||
|
let (tracks_before, ids_before): (Vec<usize>, Vec<_>) = (
|
||||||
|
fx.files[&id].trk.iter().map(|t| t.trkseg.len()).collect(),
|
||||||
|
fx.files[&id].trk.iter().map(|t| t.id).collect(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Reverse.apply(&mut fx.state()).unwrap();
|
||||||
|
|
||||||
|
let after = all_segments(&fx, id);
|
||||||
|
assert_eq!(after.len(), before.len());
|
||||||
|
// the whole path is the opposite one
|
||||||
|
let path =
|
||||||
|
|segments: &[TrackSegment]| -> Vec<f64> { segments.iter().flat_map(lngs).collect() };
|
||||||
|
let mut expected = path(&before);
|
||||||
|
expected.reverse();
|
||||||
|
assert_eq!(path(&after), expected);
|
||||||
|
// the tracks are in the opposite order, each with the same number of segments
|
||||||
|
let tracks_after: Vec<usize> = fx.files[&id].trk.iter().map(|t| t.trkseg.len()).collect();
|
||||||
|
let mut reversed = tracks_before.clone();
|
||||||
|
reversed.reverse();
|
||||||
|
assert_eq!(tracks_after, reversed);
|
||||||
|
let ids_after: Vec<_> = fx.files[&id].trk.iter().map(|t| t.id).collect();
|
||||||
|
let mut reversed_ids = ids_before;
|
||||||
|
reversed_ids.reverse();
|
||||||
|
assert_eq!(ids_after, reversed_ids);
|
||||||
|
// the segments changed, and they still end where they should
|
||||||
|
assert!(after.iter().all(|s| s.rev_id != before[0].rev_id));
|
||||||
|
for segment in &after {
|
||||||
|
assert_eq!(segment[0].anchor, Some(0));
|
||||||
|
assert_eq!(segment[segment.len() - 1].anchor, Some(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reversing_a_file_twice_gives_the_same_path_and_times() {
|
||||||
|
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||||
|
let before = all_segments(&fx, id);
|
||||||
|
let times = |segments: &[TrackSegment]| -> Vec<Option<i64>> {
|
||||||
|
segments
|
||||||
|
.iter()
|
||||||
|
.flat_map(|s| s.iter().map(|p| p.time))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
Reverse.apply(&mut fx.state()).unwrap();
|
||||||
|
Reverse.apply(&mut fx.state()).unwrap();
|
||||||
|
let after = all_segments(&fx, id);
|
||||||
|
for (a, b) in before.iter().zip(&after) {
|
||||||
|
assert_eq!(lngs(a), lngs(b));
|
||||||
|
assert_eq!(a.id, b.id);
|
||||||
|
}
|
||||||
|
assert_eq!(times(&before), times(&after));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reversing_segments_keeps_the_others() {
|
||||||
|
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||||
|
let file = fx.files[&id].clone();
|
||||||
|
let trk = &file.trk[0];
|
||||||
|
assert!(trk.trkseg.len() >= 2);
|
||||||
|
let target = &trk.trkseg[1];
|
||||||
|
fx.selection = Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: trk.id,
|
||||||
|
trkseg_ids: HashSet::from([target.id]),
|
||||||
|
};
|
||||||
|
Reverse.apply(&mut fx.state()).unwrap();
|
||||||
|
|
||||||
|
let after = &fx.files[&id].trk[0];
|
||||||
|
// the segment is reversed in place
|
||||||
|
let mut expected = lngs(target);
|
||||||
|
expected.reverse();
|
||||||
|
assert_eq!(lngs(&after.trkseg[1]), expected);
|
||||||
|
assert_eq!(after.trkseg[1].id, target.id);
|
||||||
|
assert_ne!(after.trkseg[1].rev_id, target.rev_id);
|
||||||
|
// the other ones are the same
|
||||||
|
for (i, segment) in trk.trkseg.iter().enumerate().filter(|(i, _)| *i != 1) {
|
||||||
|
assert_eq!(after.trkseg[i].rev_id, segment.rev_id, "{i}");
|
||||||
|
}
|
||||||
|
assert_eq!(fx.files[&id].trk.len(), file.trk.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reversing_tracks_reverses_their_segments_in_place() {
|
||||||
|
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
|
||||||
|
let file = fx.files[&id].clone();
|
||||||
|
let (index, trk) = file
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(_, t)| t.trkseg.len() >= 2)
|
||||||
|
.unwrap();
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: HashSet::from([trk.id]),
|
||||||
|
};
|
||||||
|
Reverse.apply(&mut fx.state()).unwrap();
|
||||||
|
|
||||||
|
let after = &fx.files[&id];
|
||||||
|
// the track stays where it is, its segments are the opposite way
|
||||||
|
assert_eq!(after.trk[index].id, trk.id);
|
||||||
|
let path = |t: &crate::Track| -> Vec<f64> { t.trkseg.iter().flat_map(lngs).collect() };
|
||||||
|
let mut expected = path(trk);
|
||||||
|
expected.reverse();
|
||||||
|
assert_eq!(path(&after.trk[index]), expected);
|
||||||
|
// and the other tracks do not change
|
||||||
|
for (i, other) in file.trk.iter().enumerate().filter(|(i, _)| *i != index) {
|
||||||
|
assert_eq!(path(&after.trk[i]), path(other));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_times_are_mirrored() {
|
||||||
|
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||||
|
let before = all_segments(&fx, id).remove(0);
|
||||||
|
Reverse.apply(&mut fx.state()).unwrap();
|
||||||
|
let after = all_segments(&fx, id).remove(0);
|
||||||
|
let (start, end) = (start_time(&before).unwrap(), end_time(&before).unwrap());
|
||||||
|
// the same period of time, the first point of the new path is the last one
|
||||||
|
assert_eq!(start_time(&after), Some(start));
|
||||||
|
assert_eq!(end_time(&after), Some(end));
|
||||||
|
let n = before.len();
|
||||||
|
for i in 0..n {
|
||||||
|
assert_eq!(
|
||||||
|
after[i].time.unwrap(),
|
||||||
|
start + end - before[n - 1 - i].time.unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(after[i].coordinates.lng, before[n - 1 - i].coordinates.lng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nothing_to_reverse() {
|
||||||
|
let (mut fx, id) = loaded("data/simple.gpx");
|
||||||
|
let before = fx.files[&id].clone();
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
assert_eq!(
|
||||||
|
Reverse.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
fx.selection = Selection::Waypoints { file_id: id };
|
||||||
|
assert_eq!(
|
||||||
|
Reverse.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||||
|
|
||||||
|
// a single trackpoint has no direction
|
||||||
|
let mut file = (*fx.files[&id]).clone();
|
||||||
|
let segment = &mut file.trk[0].trkseg[0];
|
||||||
|
let len = segment.len();
|
||||||
|
segment.splice(1, len, vec![]);
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: HashSet::from([id]),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
Reverse.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 1420 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user