Compare commits
662
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9738fbe52e | ||
|
|
64e0d5de03
|
||
|
|
2cf32128ba
|
||
|
|
2b900afc30
|
||
|
|
d059d031bd
|
||
|
|
caff1a07cc
|
||
|
|
fe766c8820
|
||
|
|
1e6a374e96
|
||
|
|
02b92308d0
|
||
|
|
a9c8a89aac | ||
|
|
a69d5644a5 | ||
|
|
eab348d187 | ||
|
|
1877455c70 | ||
|
|
0b6074aaba | ||
|
|
875d809189 | ||
|
|
3960f5cb5d | ||
|
|
07368a408c | ||
|
|
844da3ac93 | ||
|
|
af27e53150 | ||
|
|
9bced78550 | ||
|
|
0129758f74 | ||
|
|
97657eeb0d | ||
|
|
99db62cada | ||
|
|
606ffe2aff | ||
|
|
d3cf4d69d6 | ||
|
|
1e4e24eef8 | ||
|
|
3036ffcc76 | ||
|
|
1a98af2844 | ||
|
|
7ecd655a53 | ||
|
|
d7f8939750 | ||
|
|
e3be95449e | ||
|
|
61a8ee5439 | ||
|
|
f0da771089 | ||
|
|
d39a750597 | ||
|
|
e32715554c | ||
|
|
731f84bf58 | ||
|
|
1c3d2b62c6 | ||
|
|
66a8b9a1a9 | ||
|
|
c165eacdeb | ||
|
|
36cfb7dc05 | ||
|
|
3ad9dfe8f4 | ||
|
|
82065515e6 | ||
|
|
74b278e972 | ||
|
|
f9ac6c35e5 | ||
|
|
975c60df63 | ||
|
|
8fed759a8a | ||
|
|
ca4f4b0833 | ||
|
|
68f9501e3b | ||
|
|
76efae59cc | ||
|
|
48c7516a66 | ||
|
|
cee26154b8 | ||
|
|
48eba6c069 | ||
|
|
ab599e7189 | ||
|
|
8b498bdc99 | ||
|
|
52b589959f | ||
|
|
7ce8a93c9f | ||
|
|
49a9bad969 | ||
|
|
92c41a264d | ||
|
|
9bdc5c9bdc | ||
|
|
f2047a62f4 | ||
|
|
af8af0db31 | ||
|
|
3d312b7f6d | ||
|
|
788686fd59 | ||
|
|
e1368b9e16 | ||
|
|
3900e74953 | ||
|
|
42f9b66ed0 | ||
|
|
fb162ff48f | ||
|
|
7c8b2f0f7d | ||
|
|
c8f5e4d8e1 | ||
|
|
a227f86d36 | ||
|
|
e3c84a795c | ||
|
|
32b62b5a22 | ||
|
|
6f747d9f85 | ||
|
|
7de915832d | ||
|
|
725a7d6282 | ||
|
|
65c0ed5d7a | ||
|
|
ed16b7dd21 | ||
|
|
2e425ad97f | ||
|
|
1399b98a73 | ||
|
|
f404c70998 | ||
|
|
91bbfc2db8 | ||
|
|
59d51fc35f | ||
|
|
fd5e163055 | ||
|
|
5820e164b0 | ||
|
|
3a4f8b58ff | ||
|
|
aa83c53db7 | ||
|
|
ceced8bfe5 | ||
|
|
b23f9f774c | ||
|
|
572bdaf9a7 | ||
|
|
a481d20f8e | ||
|
|
74812d5528 | ||
|
|
3e3c028fb8 | ||
|
|
8373aec5a4 | ||
|
|
618e9693ae | ||
|
|
3b2b18713f | ||
|
|
66d035546d | ||
|
|
d725eeea7f | ||
|
|
0ceaa500e5 | ||
|
|
6fc3b37076 | ||
|
|
90e7805363 | ||
|
|
de9b372543 | ||
|
|
0fa80e015b | ||
|
|
a1e55680e6 | ||
|
|
2ea4481422 | ||
|
|
d0226d9bc8 | ||
|
|
3ca6f59e0b | ||
|
|
21841da60d | ||
|
|
c78df673a8 | ||
|
|
7bec552612 | ||
|
|
a2d2a8edd3 | ||
|
|
0876ae5955 | ||
|
|
ab9e8f5063 | ||
|
|
864fe26c95 | ||
|
|
79ebc617a4 | ||
|
|
ff84cce5ec | ||
|
|
953ce75b26 | ||
|
|
7eb68a1436 | ||
|
|
ed6b4f99ee | ||
|
|
c29590c51e | ||
|
|
544ffa82fd | ||
|
|
133f0992f9 | ||
|
|
deb2fd536f | ||
|
|
46586dc2e4 | ||
|
|
b255ef907a | ||
|
|
cf9b1cc6e2
|
||
|
|
d78ef0b38b
|
||
|
|
30eb5657ce
|
||
|
|
4085149e88
|
||
|
|
1788d734d2 | ||
|
|
b53215e4ba | ||
|
|
df19f9640f | ||
|
|
31aa9630d0 | ||
|
|
b1019aeb0b | ||
|
|
63aa14e31a | ||
|
|
2d87507fa4
|
||
|
|
bb8dc15554 | ||
|
|
b3369f6390 | ||
|
|
46df3b5dbe | ||
|
|
3ab93c7342 | ||
|
|
a8c4529ee8 | ||
|
|
e7a3f09547 | ||
|
|
579cf75ca2 | ||
|
|
121b34c034 | ||
|
|
97014e9454 | ||
|
|
674106923f | ||
|
|
e21dad8ed7 | ||
|
|
b2cca2332b | ||
|
|
13ad82df95 | ||
|
|
2befea2325 | ||
|
|
f45cc57049 | ||
|
|
a7f05d5d80 | ||
|
|
269e844a4c | ||
|
|
818fc49e3d | ||
|
|
8a3f7f7621 | ||
|
|
8c50670a9d | ||
|
|
141ea67d2e | ||
|
|
291fae7aec
|
||
|
|
7a04440eca | ||
|
|
691d2e4ea1 | ||
|
|
dd5c9387f9 | ||
|
|
6038d132ec | ||
|
|
6a0ac3e5c7 | ||
|
|
327a6550b4 | ||
|
|
7027d0ffb1 | ||
|
|
15b33b59ed | ||
|
|
36787717ff | ||
|
|
c27aef23c0 | ||
|
|
a9e567d299 | ||
|
|
468fb4ab13 | ||
|
|
e952de3c17 | ||
|
|
da1793bc21 | ||
|
|
650a59b1ac | ||
|
|
5a9284749c | ||
|
|
981fc5ebf4 | ||
|
|
78f5fe7c35 | ||
|
|
bdf2911a1c | ||
|
|
40b2040f25 | ||
|
|
48c88d85a4 | ||
|
|
727c828128 | ||
|
|
cce6719158 | ||
|
|
13a79baa2b | ||
|
|
c956fb9876 | ||
|
|
339ac468f3 | ||
|
|
f1eacee394 | ||
|
|
a000fb4a3d | ||
|
|
16ff2e1bc8 | ||
|
|
6ee16f02ab | ||
|
|
fd05d9b6f5 | ||
|
|
eece41d0ba | ||
|
|
b609704cc1 | ||
|
|
39436f473a | ||
|
|
ce2721025a | ||
|
|
371a2aa35f | ||
|
|
0f986616ae | ||
|
|
eb948c2e59 | ||
|
|
4fe43c5fc6 | ||
|
|
eed23b35d4 | ||
|
|
e9d46d0824 | ||
|
|
ccd18f3717 | ||
|
|
1e78f269c1 | ||
|
|
0740c00014 | ||
|
|
740526104b | ||
|
|
f807fa62d2 | ||
|
|
18725261e3 | ||
|
|
cc1a0c1aca
|
||
|
|
f2a5efcec5
|
||
|
|
cfa784991b
|
||
|
|
14f8543d3d
|
||
|
|
5705749d12
|
||
|
|
1d0b814e77 | ||
|
|
ecc26f811a | ||
|
|
8cd93d866b | ||
|
|
6bf4034e1e | ||
|
|
977321ddb5 | ||
|
|
bee96b5d9b | ||
|
|
6d21f0da6d | ||
|
|
445ccbcac0 | ||
|
|
225e5bfdc8 | ||
|
|
0c446f0d59 | ||
|
|
7c0b9bda80 | ||
|
|
eaa0278764 | ||
|
|
eeac01f638 | ||
|
|
9f8d5aaa18 | ||
|
|
cdb9d51257 | ||
|
|
2d6b895f57 | ||
|
|
80e191a50d | ||
|
|
4114185a73 | ||
|
|
24fe56c02a | ||
|
|
d83234cf5b | ||
|
|
e608b22c0c | ||
|
|
f8ed30cd73
|
||
|
|
c50933908c
|
||
|
|
77edae3585 | ||
|
|
50c152084e | ||
|
|
b0a107c749 | ||
|
|
91050826b2 | ||
|
|
1004119fed | ||
|
|
d262331d1b | ||
|
|
7440e85987 | ||
|
|
2351512af8 | ||
|
|
ae2cde3545 | ||
|
|
1000319b8f
|
||
|
|
8c4c5e16e0
|
||
|
|
0ce1e604a8
|
||
|
|
757783534c
|
||
|
|
3eba48ea7d
|
||
|
|
cf886b9581 | ||
|
|
f2ff152c07 | ||
|
|
19b152db8c | ||
|
|
18dff78e5b | ||
|
|
9b16a30c9a | ||
|
|
62bf040093 | ||
|
|
60bafc6798 | ||
|
|
bf206efc1f | ||
|
|
c42f72a856 | ||
|
|
c6a890aee3 | ||
|
|
59450cf480 | ||
|
|
7f068ea10f | ||
|
|
f9763d7b53 | ||
|
|
f2e9f44dee | ||
|
|
1039e220fb | ||
|
|
a810345e2b | ||
|
|
4dc5213e6f | ||
|
|
5f313a20d5 | ||
|
|
01eae6daf9 | ||
|
|
7ae7ebb9b3 | ||
|
|
a4e64e43f3 | ||
|
|
a1e73f5844 | ||
|
|
a6189c77ef | ||
|
|
1b36ae84d7 | ||
|
|
c609cccdc6 | ||
|
|
1360c5eeef | ||
|
|
d087eddc29 | ||
|
|
1910a8672b | ||
|
|
787175ee37 | ||
|
|
90285d5efa | ||
|
|
66b06d608d | ||
|
|
cabaa02f59 | ||
|
|
ee60dffa17 | ||
|
|
98a2558ad2 | ||
|
|
d820c0e5fa | ||
|
|
61bba0c099 | ||
|
|
c58f1359cd | ||
|
|
942e3a6235 | ||
|
|
d43535f772 | ||
|
|
e12c7194e6 | ||
|
|
47277d3609 | ||
|
|
2a8bb6d1b4 | ||
|
|
7ad2eff27b | ||
|
|
b52c16c242 | ||
|
|
caeb0f69a5 | ||
|
|
74b4fe4ac4 | ||
|
|
504ae87174 | ||
|
|
06bba3ceaf | ||
|
|
70defaf916 | ||
|
|
dfcebe327f | ||
|
|
3b27613e99 | ||
|
|
bff39001a4 | ||
|
|
e77c8dc49e | ||
|
|
c4da784b95 | ||
|
|
cbf1871cec | ||
|
|
9ed40a67bc | ||
|
|
8e699d0b22 | ||
|
|
9424517eb4 | ||
|
|
acd6753112 | ||
|
|
6fa02d117b | ||
|
|
f83ff7aca1 | ||
|
|
89bd79b542 | ||
|
|
4db4757701 | ||
|
|
f57f29cda6 | ||
|
|
51ff021d37 | ||
|
|
c6f9bf7fc5 | ||
|
|
9385442542 | ||
|
|
19a185a6c3 | ||
|
|
725de6c7b3 | ||
|
|
5228ac2508 | ||
|
|
80d6693ef3 | ||
|
|
9d3ce7410a | ||
|
|
989fd1cea0 | ||
|
|
d3b11fbb02 | ||
|
|
9a8c13e148 | ||
|
|
4f65a86302 | ||
|
|
8c559f73f7 | ||
|
|
1e77399412 | ||
|
|
6f2d9cacc4 | ||
|
|
f6209562ff | ||
|
|
9e692dae6a | ||
|
|
16458b014f | ||
|
|
af262b0ad2 | ||
|
|
5a81ae5170 | ||
|
|
89931b9c32 | ||
|
|
5a07720457 | ||
|
|
7dd4049f89 | ||
|
|
6aa1253ab6 | ||
|
|
3391985d20 | ||
|
|
28bb0ae611 | ||
|
|
d7a3f5389e | ||
|
|
0552dc33c0 | ||
|
|
b0799070eb | ||
|
|
b000b2e921 | ||
|
|
10c5f9538f | ||
|
|
5a47c70a6f | ||
|
|
f768306651 | ||
|
|
ff22ea6355 | ||
|
|
f22ff6eb34 | ||
|
|
12812dcc2f | ||
|
|
c348a2c8a6 | ||
|
|
417f53860d | ||
|
|
7dc7381075 | ||
|
|
b86c1aeebe | ||
|
|
1d32687848 | ||
|
|
80738118f0 | ||
|
|
45776a2f9b | ||
|
|
b303bc6aa6 | ||
|
|
476dcddaa9 | ||
|
|
5229507771 | ||
|
|
9a16f1b2ce | ||
|
|
633a7b5d06 | ||
|
|
80ff0458a1 | ||
|
|
5d5d6c6962 | ||
|
|
2c94f7871b | ||
|
|
26a4aae265 | ||
|
|
477a8c94fd | ||
|
|
c00078e766 | ||
|
|
09ba61f3c5 | ||
|
|
71d48d87be | ||
|
|
9695f69e70 | ||
|
|
992116fcfe | ||
|
|
270946c082 | ||
|
|
6832f3ebee | ||
|
|
ce47bff4f5 | ||
|
|
47c8cf7812 | ||
|
|
5cd1b5f9db | ||
|
|
e3a6b11c67 | ||
|
|
8fb08205f1 | ||
|
|
863bba990a | ||
|
|
cc7f0f6a47 | ||
|
|
c4432e6494 | ||
|
|
a4237d102e | ||
|
|
5050468192 | ||
|
|
e526ea9974 | ||
|
|
36cd230e16 | ||
|
|
b55b834e35 | ||
|
|
f60ff9e9e6 | ||
|
|
b8618319b0 | ||
|
|
92e4fbeb7a | ||
|
|
0d1ff771dd | ||
|
|
486ccd2a63 | ||
|
|
560005a943 | ||
|
|
40fdf3f536 | ||
|
|
07172fded1 | ||
|
|
f7f499c4c7 | ||
|
|
3ed7eeebcd | ||
|
|
5b7e497bc6 | ||
|
|
5749aca451 | ||
|
|
fd57d75063 | ||
|
|
17be04d259 | ||
|
|
08fb6902db | ||
|
|
4cf95f81e3 | ||
|
|
11ab88e25c | ||
|
|
28e9eb20b9 | ||
|
|
c728e49731 | ||
|
|
4cb421ec7a | ||
|
|
ffe7f6c36d | ||
|
|
e65475aca7 | ||
|
|
1ae85ab23c | ||
|
|
2eb83d6e3d | ||
|
|
fbebc001f6 | ||
|
|
e3105a920b | ||
|
|
791cc5df34 | ||
|
|
92c6c26d49 | ||
|
|
db86caa4e4 | ||
|
|
17ce5da002 | ||
|
|
d514362719 | ||
|
|
71865d28f5 | ||
|
|
5b9ac2b00b | ||
|
|
41cf46f182 | ||
|
|
007361fa98 | ||
|
|
94fbad41e8 | ||
|
|
f525b5fb68 | ||
|
|
39dd9f76c5 | ||
|
|
d7980ee737 | ||
|
|
08ad167c12 | ||
|
|
e713357246 | ||
|
|
99b82b5d6e | ||
|
|
d7f12fdacb | ||
|
|
759645e31e | ||
|
|
5e50f5733c | ||
|
|
db523412c5 | ||
|
|
75a772f4fe | ||
|
|
e4fa5430ba | ||
|
|
db321c4d74 | ||
|
|
14bda79c07 | ||
|
|
0cf5511aad | ||
|
|
2e9aa07b71 | ||
|
|
2dd6722ac5 | ||
|
|
b15bd32edf | ||
|
|
a1daa4d1eb | ||
|
|
9914010eb6 | ||
|
|
6353a2b8b7 | ||
|
|
9f8c061f29 | ||
|
|
ef4e8f64dd | ||
|
|
2065e60c3b | ||
|
|
ac0eeefb26 | ||
|
|
22343cc7c6 | ||
|
|
c8840fe925 | ||
|
|
0bc2614c0e | ||
|
|
28400ede30 | ||
|
|
91c996a0f1 | ||
|
|
bb602ea38f | ||
|
|
fc33efecf7 | ||
|
|
832b7d0ee1 | ||
|
|
2755201920 | ||
|
|
7c953a706a | ||
|
|
d26adae47c | ||
|
|
940aba5863 | ||
|
|
a88ea3a5b8 | ||
|
|
af7b3e7b2c | ||
|
|
57007049b4 | ||
|
|
e52e8cba27 | ||
|
|
3a675af015 | ||
|
|
07c2926d47 | ||
|
|
bc29c93e72 | ||
|
|
a8b0c99b1c | ||
|
|
027af22a55 | ||
|
|
c5ea332f6e | ||
|
|
c80dcfc721 | ||
|
|
9c415da2cf | ||
|
|
52a4bc1c21 | ||
|
|
45128b0c41 | ||
|
|
97649f9f43 | ||
|
|
3de4677121 | ||
|
|
03f9777c9a | ||
|
|
1124d239d9 | ||
|
|
4efc445ac5 | ||
|
|
960a167b44 | ||
|
|
d4eeadc4f5 | ||
|
|
3936601599 | ||
|
|
453ba4c867 | ||
|
|
55d25db229 | ||
|
|
528789cbfe | ||
|
|
867584dbb0 | ||
|
|
e19c8eba7f | ||
|
|
867ec40141 | ||
|
|
dd0fd4041c | ||
|
|
b772d8769c | ||
|
|
92b6e98060 | ||
|
|
0919c237c8 | ||
|
|
afd79eb2c7 | ||
|
|
d953984df1 | ||
|
|
f606a5c57c | ||
|
|
3e1bfe44df | ||
|
|
63a552934c | ||
|
|
74faab692d | ||
|
|
23c866c743 | ||
|
|
8836c41622 | ||
|
|
f1249506a6 | ||
|
|
9b62593811 | ||
|
|
b201c770e8 | ||
|
|
94e64bcf0d | ||
|
|
12f7913e77 | ||
|
|
5866bf837c | ||
|
|
9c11c96c00 | ||
|
|
6a13d7a779 | ||
|
|
c71129d6a3 | ||
|
|
a588c8509d | ||
|
|
b93082dab1 | ||
|
|
495312e7cd | ||
|
|
01d7903fa6 | ||
|
|
cf30d5f265 | ||
|
|
6294502850 | ||
|
|
1b692865dc | ||
|
|
e20eee2f84 | ||
|
|
8d61249b6a | ||
|
|
2265e4f4cc | ||
|
|
e6b290d991 | ||
|
|
b4da9656d6 | ||
|
|
34079e700d | ||
|
|
910d1de178 | ||
|
|
e2296859c5 | ||
|
|
267126e664 | ||
|
|
fe0ee5c463 | ||
|
|
064be48564 | ||
|
|
b8a1a0e0bc | ||
|
|
fb30049f04 | ||
|
|
4c6a556cc8 | ||
|
|
2c45e4dfe4 | ||
|
|
c2eec3965f | ||
|
|
dad18f6c0c | ||
|
|
3bf8705179 | ||
|
|
c1df3f6a92 | ||
|
|
cfc5ba086a | ||
|
|
1431d384db | ||
|
|
9fad8233b2 | ||
|
|
b985b9dee0 | ||
|
|
22ced4b782 | ||
|
|
64090e0eb1 | ||
|
|
11cf1787b2 | ||
|
|
2ca47892c1 | ||
|
|
ec71180c95 | ||
|
|
db10def0fb | ||
|
|
38b0995523 | ||
|
|
a1384e7594 | ||
|
|
fac01dc0ca | ||
|
|
063649a5df | ||
|
|
bb6baa3fe8 | ||
|
|
6e0b2632ff | ||
|
|
33cd4c17da | ||
|
|
62eb91bdf3 | ||
|
|
6a717ca652 | ||
|
|
0eb8337089 | ||
|
|
a68eceab6d | ||
|
|
78fefa9bd2 | ||
|
|
dcf8f05657 | ||
|
|
0a327cbea1 | ||
|
|
240669f9f2 | ||
|
|
b1c1fe78f3 | ||
|
|
afab88a939 | ||
|
|
0b5669cb52 | ||
|
|
e7af455803 | ||
|
|
ca8231d988 | ||
|
|
1cf43f41d3 | ||
|
|
52d2aa37fa | ||
|
|
b1a96d1003 | ||
|
|
d858795a54 | ||
|
|
dbec6ade39 | ||
|
|
937cd31bac | ||
|
|
ad03d60934 | ||
|
|
55f9e52c19 | ||
|
|
af7ebfc6fb | ||
|
|
2b3c4cea80 | ||
|
|
16bfc6cbba | ||
|
|
aa0dfa23fa | ||
|
|
4c245337bd | ||
|
|
00adc184d7 | ||
|
|
32210875c4 | ||
|
|
63a10cbd12 | ||
|
|
43cca9e874 | ||
|
|
d97c63f75f | ||
|
|
50e5e74f59 | ||
|
|
94561e2f96 | ||
|
|
23513fc8fb | ||
|
|
bea9bb0b96 | ||
|
|
0da8f01a92 | ||
|
|
ede977452d | ||
|
|
e12494e5ed | ||
|
|
1c7c66ee50 | ||
|
|
88f86c75c8 | ||
|
|
036db580f5 | ||
|
|
6dcd4a9e25 | ||
|
|
da276fb8f8 | ||
|
|
05301b7bd5 | ||
|
|
78c35c28b3 | ||
|
|
863ede58b4 | ||
|
|
7d6692b17a | ||
|
|
4f31eadbd4 | ||
|
|
56d2d2684d | ||
|
|
0668c65bfe | ||
|
|
abe3d5e958 | ||
|
|
7379e27f90 | ||
|
|
be8b297235 | ||
|
|
64ad296815 | ||
|
|
17266f3c0b | ||
|
|
0d9cae6550 | ||
|
|
41c1eb1257 | ||
|
|
4a6164ac84 | ||
|
|
aec008e7be | ||
|
|
0d76b994a2 | ||
|
|
14bfaabb11 | ||
|
|
d77c5ec391 | ||
|
|
b793ac7a6e | ||
|
|
d04e78694c | ||
|
|
d6dd4e6b96 | ||
|
|
44e5c54c77 | ||
|
|
bdb49cd085 | ||
|
|
e76aca71eb | ||
|
|
98321b4c3b | ||
|
|
b082fc44dc | ||
|
|
0776814283 | ||
|
|
3a9718d1a7 | ||
|
|
d9ef921a43 | ||
|
|
c415e8a829 | ||
|
|
daa291d5de | ||
|
|
d2987ebe1e | ||
|
|
ed32fd1183 | ||
|
|
83b1ec9c92 | ||
|
|
5e982e93ee | ||
|
|
846081536d | ||
|
|
9f2f7844f9 | ||
|
|
9966bba33c | ||
|
|
ae59b054c9 | ||
|
|
8bed6b040b | ||
|
|
d1933ec661 | ||
|
|
b40932624a | ||
|
|
6c5e727c67 | ||
|
|
6420ae2f13 | ||
|
|
91b234777d | ||
|
|
b1a646f1f2 | ||
|
|
89134681f3 | ||
|
|
3b670b8ce2 | ||
|
|
b0ba07a7fb | ||
|
|
07adda1d74 | ||
|
|
6a86d05bad | ||
|
|
dc94c465d6 | ||
|
|
950bfbb42e | ||
|
|
927ac05f03 | ||
|
|
d6d84c6892 | ||
|
|
a34c1dbb11 | ||
|
|
6df13a044b | ||
|
|
68fb0023da | ||
|
|
686982c096 | ||
|
|
84898895df | ||
|
|
472237ba8f
|
||
|
|
2ae7e1fb8c | ||
|
|
ed4d751dce | ||
|
|
b5274d2113 | ||
|
|
d7ad8ebd74 | ||
|
|
43a521de16 | ||
|
|
4cf8c497a8 | ||
|
|
fd57b5dee4 | ||
|
|
49b80dc3b9 | ||
|
|
c0e3943700 |
@@ -23,8 +23,10 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: set up secrets
|
||||
run: |
|
||||
echo "${{ secrets.RELEASE_KEYSTORE }}" > keystore.asc
|
||||
@@ -32,7 +34,6 @@ jobs:
|
||||
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch keystore.asc > app/keystore.properties
|
||||
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch key.asc > app/key.jks
|
||||
|
||||
- uses: gradle/wrapper-validation-action@v3
|
||||
|
||||
- name: create and checkout branch
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -41,17 +42,38 @@ jobs:
|
||||
run: git checkout -B "$BRANCH"
|
||||
|
||||
- name: set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v6
|
||||
with:
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
distribution: "temurin"
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Run unit tests
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Build APK
|
||||
run: ./gradlew assembleSignedRelease
|
||||
run: |
|
||||
VERSION_CODE=$(git rev-list --count HEAD)
|
||||
VERSION_BASE=$(git describe --tags --abbrev=0 | sed 's/^v//')
|
||||
VERSION_DEV=$(git rev-list --count $(git describe --tags --abbrev=0)..HEAD)
|
||||
if [ $VERSION_DEV -gt 0 ]; then
|
||||
VERSION_NAME="${VERSION_BASE}.${VERSION_DEV}"
|
||||
else
|
||||
VERSION_NAME="${VERSION_BASE}"
|
||||
fi
|
||||
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
path: app/build/outputs/apk/signedRelease/app-signedRelease.apk
|
||||
compression-level: 0
|
||||
archive: false
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: |
|
||||
|
||||
+3
-1
@@ -22,4 +22,6 @@ app/release/
|
||||
captures/
|
||||
local.properties
|
||||
keystore.properties
|
||||
key.jks
|
||||
key.jks
|
||||
# generated map intermediates; the packed .bmap in assets is what ships
|
||||
mapdata/*.geojson
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
<p>A virtual scratchmap of the world</p>
|
||||
|
||||
<a href="https://ko-fi.com/I2I615VP5M"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="ko-fi"></a>
|
||||
<br>
|
||||
<img src="https://forthebadge.com/images/badges/built-for-android.svg" alt="Built for Android">
|
||||
<img src="https://forthebadge.com/images/badges/built-with-love.svg" alt="Built with love">
|
||||
<br>
|
||||
@@ -71,7 +73,7 @@ Thanks to all contributors, the developers of our dependencies, and our users.
|
||||
## 📝 License
|
||||
|
||||
```
|
||||
Copyright (C) 2024 Helcel & MYDOLI
|
||||
Copyright (C) 2026 Helcel & MYDOLI
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
|
||||
+30
-39
@@ -1,27 +1,24 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.20'
|
||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.2.20'
|
||||
id 'com.mikepenz.aboutlibraries.plugin' version '12.2.4'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.10'
|
||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.10'
|
||||
}
|
||||
|
||||
|
||||
android {
|
||||
namespace 'net.helcel.beans'
|
||||
compileSdk 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
buildConfigField("String", "APP_NAME", "\"Beans\"")
|
||||
manifestPlaceholders["APP_NAME"] = "Beans"
|
||||
applicationId 'net.helcel.beans'
|
||||
minSdk 28
|
||||
targetSdk 36
|
||||
versionCode 4
|
||||
versionName "1.1a"
|
||||
minSdk = 28
|
||||
targetSdk = 37
|
||||
versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.5"
|
||||
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
|
||||
}
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
register("release") {
|
||||
try {
|
||||
def keystorePropertiesFile = rootProject.file("app/keystore.properties")
|
||||
def keystoreProperties = new Properties()
|
||||
@@ -36,20 +33,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
debuggable true
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources false
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
signedRelease {
|
||||
minifyEnabled true
|
||||
shrinkResources false
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
@@ -78,8 +73,10 @@ android {
|
||||
kotlinCompilerExtensionVersion = "2.2.20"
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
androidResources {
|
||||
// The app ships English strings only; without this the libraries drag
|
||||
// in some eighty locales of their own.
|
||||
localeFilters += ["en"]
|
||||
}
|
||||
|
||||
lint {
|
||||
@@ -87,34 +84,28 @@ android {
|
||||
}
|
||||
|
||||
}
|
||||
aboutLibraries {
|
||||
library {
|
||||
exclusionPatterns = [~"androidx.*", ~"com.google.android.*", ~"org.jetbrains.*"]
|
||||
}
|
||||
excludeFields = ["generated"]
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.compose.material3:material3:1.3.2'
|
||||
implementation 'androidx.navigation:navigation-compose:2.9.4'
|
||||
implementation 'androidx.compose.material3:material3:1.4.0'
|
||||
implementation "androidx.compose.material:material:1.12.0"
|
||||
implementation 'androidx.compose.material:material-icons-extended:1.7.8'
|
||||
implementation 'androidx.navigation:navigation-compose:2.10.0'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0'
|
||||
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation "androidx.compose.material:material:1.9.1"
|
||||
implementation "androidx.activity:activity-ktx:1.11.0"
|
||||
implementation "androidx.activity:activity-ktx:1.13.0"
|
||||
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'com.google.android.material:material:1.13.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0'
|
||||
|
||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||
implementation 'com.github.chrisbanes:PhotoView:2.3.0'
|
||||
|
||||
implementation 'com.mikepenz:aboutlibraries:12.2.4'
|
||||
implementation 'com.mikepenz:aboutlibraries-compose-m3:12.2.4'
|
||||
implementation 'com.mikepenz:aboutlibraries-core:12.2.4'
|
||||
|
||||
|
||||
implementation platform('androidx.compose:compose-bom:2025.09.00')
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling:1.9.1'
|
||||
implementation platform('androidx.compose:compose-bom:2026.08.00')
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling:1.12.0'
|
||||
}
|
||||
|
||||
tasks.configureEach { task ->
|
||||
if (task.name.startsWith("merge") && task.name.endsWith("Assets")) {
|
||||
task.outputs.upToDateWhen { false }
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,7 @@
|
||||
android:hardwareAccelerated="false"
|
||||
android:icon="@mipmap/ic_launcher_round"
|
||||
android:label="${APP_NAME}"
|
||||
android:supportsRtl="true"
|
||||
tools:replace="android:allowBackup">
|
||||
android:supportsRtl="true">
|
||||
<activity
|
||||
android:name=".activity.MainScreen"
|
||||
android:exported="true">
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 6.1 MiB |
+3104
-3102
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 6.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 6.1 MiB |
@@ -19,13 +19,15 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.helcel.beans.R
|
||||
import net.helcel.beans.activity.sub.EditPlaceScreen
|
||||
import net.helcel.beans.countries.World
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun EditScreen(onExit:()->Unit) {
|
||||
fun EditScreen(onExit:()->Unit = {}) {
|
||||
SysTheme {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package net.helcel.beans.activity
|
||||
|
||||
import android.graphics.drawable.PictureDrawable
|
||||
import android.os.Bundle
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
@@ -16,62 +18,86 @@ import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Percent
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.caverock.androidsvg.RenderOptions
|
||||
import com.github.chrisbanes.photoview.PhotoView
|
||||
import net.helcel.beans.BuildConfig
|
||||
import net.helcel.beans.activity.sub.EditPlaceDialog
|
||||
import net.helcel.beans.activity.sub.MapPickDialog
|
||||
import net.helcel.beans.activity.sub.applyDirectVisit
|
||||
import net.helcel.beans.activity.sub.commitVisitDialog
|
||||
import net.helcel.beans.countries.GeoLoc
|
||||
import net.helcel.beans.countries.GeoLocImporter
|
||||
import net.helcel.beans.countries.GeoLocTree
|
||||
import net.helcel.beans.helper.Data
|
||||
import net.helcel.beans.helper.Settings
|
||||
import net.helcel.beans.svg.CSSWrapper
|
||||
import net.helcel.beans.svg.SVGWrapper
|
||||
import net.helcel.beans.map.MapAssets
|
||||
import net.helcel.beans.map.MapStyle
|
||||
import net.helcel.beans.map.MapView
|
||||
import net.helcel.beans.map.MapWorld
|
||||
import net.helcel.beans.map.MapReader
|
||||
|
||||
|
||||
class MainScreen : ComponentActivity() {
|
||||
|
||||
private lateinit var psvg: SVGWrapper
|
||||
private lateinit var css: CSSWrapper
|
||||
private var world by mutableStateOf<MapWorld?>(null)
|
||||
private var loadToken = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
actionBar?.hide()
|
||||
Settings.start(this)
|
||||
GeoLocImporter.importStates(this)
|
||||
Data.loadData(this, Int.MIN_VALUE)
|
||||
GeoLocImporter.importStates(this)
|
||||
|
||||
refreshProjection()
|
||||
|
||||
setContent {
|
||||
SysTheme {
|
||||
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background)) {
|
||||
AppNavHost(psvg, css)
|
||||
// Both bars: without the navigation inset the system buttons sit on top of
|
||||
// whatever is at the bottom of a screen, such as the About button.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.primary)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
AppNavHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshProjection()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavHost(psvg: SVGWrapper, css: CSSWrapper) {
|
||||
fun AppNavHost() {
|
||||
val navController = rememberNavController()
|
||||
NavHost(navController, startDestination = "main") {
|
||||
composable("main") { MainScreenC(psvg,css, navController) }
|
||||
composable("settings") { SettingsMainScreen { navController.navigate("main")} }
|
||||
composable("main") { MainScreenC(world, navController) }
|
||||
composable("settings") { SettingsMainScreen { navController.navigate("main") } }
|
||||
composable("edit") { EditScreen { navController.navigate("main") } }
|
||||
composable("stats") { StatsScreen { navController.navigate("main") } }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreenC(psvg: SVGWrapper,css: CSSWrapper, nav: NavHostController){
|
||||
fun MainScreenC(world: MapWorld?, nav: NavHostController){
|
||||
SysTheme {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -82,7 +108,7 @@ class MainScreen : ComponentActivity() {
|
||||
Icon(Icons.Default.Edit, contentDescription = "Edit")
|
||||
}
|
||||
IconButton(onClick = { nav.navigate("stats") }){
|
||||
Icon(Icons.Default.DateRange, contentDescription = "Stats")
|
||||
Icon(Icons.Default.Percent, contentDescription = "Stats")
|
||||
}
|
||||
IconButton(onClick = { nav.navigate("settings") }) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
@@ -91,33 +117,87 @@ class MainScreen : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.padding(innerPadding)) {
|
||||
MapScreen(psvg, css)
|
||||
Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
|
||||
if (world == null) LoadingMap() else MapScreen(world)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapScreen(psvg: SVGWrapper, css: CSSWrapper) {
|
||||
Box {
|
||||
val opt: RenderOptions = RenderOptions.create()
|
||||
opt.css(css.get())
|
||||
val drawable = remember(psvg, css) {
|
||||
PictureDrawable(psvg.get()?.renderToPicture(opt))
|
||||
}
|
||||
AndroidView(factory = { ctx ->
|
||||
PhotoView(ctx).apply {
|
||||
setLayerType(ImageView.LAYER_TYPE_SOFTWARE, null)
|
||||
setImageDrawable(drawable)
|
||||
scaleType = ImageView.ScaleType.FIT_CENTER
|
||||
}
|
||||
}, modifier = Modifier.fillMaxSize())
|
||||
fun LoadingMap() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colors.primary,
|
||||
strokeWidth = 4.dp,
|
||||
modifier = Modifier.size(50.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapScreen(world: MapWorld) {
|
||||
val ctx = LocalContext.current
|
||||
val visits by Data.visits.visitsFlow.collectAsState()
|
||||
val groups by Data.groups.groupsFlow.collectAsState()
|
||||
val land = MaterialTheme.colors.onBackground.toArgb()
|
||||
val background = MaterialTheme.colors.background.toArgb()
|
||||
val style = remember(visits, groups, land, background) {
|
||||
MapStyle.build(ctx, land, background)
|
||||
}
|
||||
val touchRadius = Settings.getTouchRadius(ctx)
|
||||
|
||||
var candidates by remember { mutableStateOf<List<GeoLoc>>(emptyList()) }
|
||||
var showColor by remember { mutableStateOf(false) }
|
||||
|
||||
// A place is either applied straight away, or the colour dialog picks
|
||||
// the group for it, exactly as it does from the edit list.
|
||||
fun select(loc: GeoLoc) {
|
||||
if (!applyDirectVisit(ctx, loc)) showColor = true
|
||||
}
|
||||
|
||||
if (candidates.isNotEmpty()) {
|
||||
MapPickDialog(
|
||||
candidates = candidates,
|
||||
onPick = { candidates = emptyList(); select(it) },
|
||||
onDismiss = { candidates = emptyList() },
|
||||
)
|
||||
}
|
||||
if (showColor) {
|
||||
EditPlaceDialog(false) { cleared ->
|
||||
showColor = false
|
||||
commitVisitDialog(cleared)
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { MapView(it) },
|
||||
update = { view ->
|
||||
view.world = world
|
||||
view.style = style
|
||||
view.touchRadiusDp = touchRadius
|
||||
// Always the tree, even for a single hit: landing on a region is
|
||||
// just as often a way of reaching the country around it.
|
||||
view.onPick = { picks ->
|
||||
val locs = picks.mapNotNull { GeoLocTree.find(it.code) }
|
||||
if (locs.isNotEmpty()) candidates = locs
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
/** Reloads the map asset, on a worker thread since it is a few megabytes. */
|
||||
fun refreshProjection() {
|
||||
psvg = SVGWrapper(this)
|
||||
css = CSSWrapper(this)
|
||||
val asset = MapAssets.assetFor(this)
|
||||
val token = ++loadToken
|
||||
world = null
|
||||
Thread {
|
||||
val parsed = assets.open(asset).use { MapReader.read(it) }
|
||||
runOnUiThread { if (token == loadToken) world = parsed }
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.RadioButton
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.Slider
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -37,6 +38,7 @@ import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -48,10 +50,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.preference.PreferenceManager
|
||||
import net.helcel.beans.R
|
||||
import net.helcel.beans.countries.GeoLocImporter
|
||||
import net.helcel.beans.helper.Settings
|
||||
import androidx.core.content.edit
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
@@ -60,18 +58,23 @@ import androidx.navigation.compose.rememberNavController
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.helcel.beans.R
|
||||
import net.helcel.beans.activity.sub.AboutScreen
|
||||
import net.helcel.beans.activity.sub.EditPlaceColorDialog
|
||||
import net.helcel.beans.activity.sub.EditPlaceDialog
|
||||
import net.helcel.beans.activity.sub.LicenseScreen
|
||||
import net.helcel.beans.countries.GeoLocImporter
|
||||
import net.helcel.beans.helper.DEFAULT_TOUCH_RADIUS
|
||||
import net.helcel.beans.helper.Data
|
||||
import net.helcel.beans.helper.defaultPreferences
|
||||
import net.helcel.beans.helper.MAX_TOUCH_RADIUS
|
||||
import net.helcel.beans.helper.Settings
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun SysTheme(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val prefs = defaultPreferences(context)
|
||||
val themeKey = prefs.getString(stringResource(R.string.key_theme), stringResource(R.string.system))
|
||||
val darkTheme = when (themeKey) {
|
||||
stringResource(R.string.system) -> isSystemInDarkTheme()
|
||||
@@ -100,27 +103,22 @@ fun SysTheme(
|
||||
isLight = !darkTheme,
|
||||
)
|
||||
|
||||
MaterialTheme(
|
||||
colors = m2colors,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun settingsNav(): NavHostController {
|
||||
val navController = rememberNavController()
|
||||
NavHost(navController, startDestination= "settings"){
|
||||
composable("settings"){SettingsScreen(navController)}
|
||||
composable("licenses"){ LicenseScreen() }
|
||||
composable("about"){ AboutScreen() }
|
||||
// Both, because the app mixes the two: most of it is Material 2, while the
|
||||
// dividers are Material 3 and would otherwise fall back to the default
|
||||
// light scheme.
|
||||
androidx.compose.material3.MaterialTheme(colorScheme = colorScheme) {
|
||||
MaterialTheme(
|
||||
colors = m2colors,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
return navController
|
||||
}
|
||||
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SettingsMainScreen(onExit: ()->Unit = {}) {
|
||||
var nav: NavHostController? = null
|
||||
val nav: NavHostController = rememberNavController()
|
||||
SysTheme {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -128,7 +126,7 @@ fun SettingsMainScreen(onExit: ()->Unit = {}) {
|
||||
title = { Text(stringResource(R.string.action_settings)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
if(nav!=null && !nav!!.popBackStack())
|
||||
if(!nav.popBackStack())
|
||||
onExit()
|
||||
}) {
|
||||
Icon(
|
||||
@@ -140,156 +138,64 @@ fun SettingsMainScreen(onExit: ()->Unit = {}) {
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
// One host, inside the scaffold. Building it here and again as a
|
||||
// default argument left two of them: settings drawn twice, and
|
||||
// only the inner one ever navigating.
|
||||
Box(modifier = Modifier.padding(innerPadding)) {
|
||||
nav = settingsNav()
|
||||
NavHost(nav, startDestination = "settings") {
|
||||
composable("settings") { SettingsScreen(nav) }
|
||||
composable("about") { AboutScreen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SettingsScreen(navController: NavHostController = settingsNav()) {
|
||||
fun SettingsScreen(navController: NavHostController) {
|
||||
val context = LocalContext.current
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
var showEdit by remember { mutableStateOf(false) }
|
||||
val prefs = defaultPreferences(context)
|
||||
val keyTheme = stringResource(R.string.key_theme)
|
||||
val defaultTheme = stringResource(R.string.system)
|
||||
val keyProjection = stringResource(R.string.key_projection)
|
||||
val keyGroup = stringResource(R.string.key_group)
|
||||
val keyRegional = stringResource(R.string.key_regional)
|
||||
val keyCascadeStats = stringResource(R.string.key_cascade_stats)
|
||||
val keyTouchRadius = stringResource(R.string.key_touch_radius)
|
||||
val offString = stringResource(R.string.off)
|
||||
val onString = stringResource(R.string.on)
|
||||
|
||||
var theme by remember { mutableStateOf(prefs.getString(context.getString(R.string.key_theme), context.getString(R.string.system))!!) }
|
||||
var projection by remember { mutableStateOf(prefs.getString(context.getString(R.string.key_projection), "default")!!) }
|
||||
var groups by remember { mutableStateOf(prefs.getString(context.getString(R.string.key_group), context.getString(R.string.off))!!) }
|
||||
|
||||
if(showEdit)
|
||||
EditPlaceDialog(true) {
|
||||
showEdit = false
|
||||
val g = Data.selected_group
|
||||
if (it && g != null)
|
||||
Data.visits.reassignAllVisitedToGroup(g.key)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
"Theme", style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
)
|
||||
MultiPreference(arrayOf(stringResource(R.string.system),stringResource(R.string.light),stringResource(R.string.dark)), theme) { newTheme ->
|
||||
theme = newTheme
|
||||
prefs.edit { putString(context.getString(R.string.key_theme), newTheme) }
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
"Map Projection",
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
MultiPreference(arrayOf(stringResource(R.string.mercator), stringResource(R.string.azimuthalequidistant)), projection) { newProj ->
|
||||
projection = newProj
|
||||
prefs.edit { putString(context.getString(R.string.key_projection), newProj) }
|
||||
Settings.refreshProjection()
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
"Groups",
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
var showDialog by remember{mutableStateOf(false)}
|
||||
if(showDialog){
|
||||
EditPlaceColorDialog(
|
||||
deleteMode = true,
|
||||
onDismiss = {
|
||||
val g = Data.selected_group
|
||||
if (g != null)
|
||||
Data.visits.reassignAllVisitedToGroup(g.key)
|
||||
showDialog = false
|
||||
})
|
||||
}
|
||||
MultiPreference(
|
||||
arrayOf(stringResource(R.string.on), stringResource(R.string.off)),
|
||||
groups
|
||||
) { key ->
|
||||
if (key == context.getString(R.string.off)) {
|
||||
showDialog=true
|
||||
}
|
||||
groups = key
|
||||
prefs.edit { putString(context.getString(R.string.key_group), key) }
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = "Regional",
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp)
|
||||
.clickable(onClick = {}),
|
||||
)
|
||||
RegionalScreen()
|
||||
HorizontalDivider()
|
||||
}
|
||||
item{
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
onResult = { uri -> Data.doImport(context, uri) }
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Button(onClick = {
|
||||
launcher.launch(arrayOf("*/*"))
|
||||
}, modifier = Modifier
|
||||
.fillMaxWidth(fraction = 0.4f)
|
||||
.padding(vertical = 8.dp)) {
|
||||
Text("Import")
|
||||
}
|
||||
Spacer(
|
||||
modifier = Modifier.fillMaxWidth(0.4f)
|
||||
)
|
||||
Button(onClick = {
|
||||
Data.doExport(context)
|
||||
}, modifier = Modifier
|
||||
.fillMaxWidth(fraction = 1f)
|
||||
.padding(vertical = 8.dp)) {
|
||||
Text("Export")
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
PreferenceButton("Licenses") {
|
||||
if (navController.currentDestination?.route != "licenses")
|
||||
navController.navigate("licenses")
|
||||
}
|
||||
PreferenceButton("About") {
|
||||
if (navController.currentDestination?.route != "about")
|
||||
navController.navigate("about")
|
||||
}
|
||||
}
|
||||
}
|
||||
var theme by remember { mutableStateOf(prefs.getString(keyTheme, defaultTheme)!!) }
|
||||
var projection by remember { mutableStateOf(prefs.getString(keyProjection, "default")!!) }
|
||||
var groups by remember { mutableStateOf(prefs.getString(keyGroup, offString)!!) }
|
||||
var regional by remember { mutableStateOf(prefs.getString(keyRegional, offString)!!) }
|
||||
var cascadeStats by remember { mutableStateOf(prefs.getString(keyCascadeStats, offString)!!) }
|
||||
var touchRadius by remember {
|
||||
mutableIntStateOf(prefs.getInt(keyTouchRadius, DEFAULT_TOUCH_RADIUS))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RegionalScreen() {
|
||||
val context = LocalContext.current
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
var selected by remember { mutableStateOf(prefs.getString(context.getString(R.string.key_regional),context.getString(R.string.off))!!)}
|
||||
var regional by remember{ mutableStateOf(prefs.getString(context.getString(R.string.key_regional), context.getString(R.string.off))!!)}
|
||||
var showDialog by remember{mutableStateOf(false)}
|
||||
var showLoad by remember{mutableStateOf(false)}
|
||||
var showGroupDialog by remember { mutableStateOf(false) }
|
||||
var showRegionalDialog by remember { mutableStateOf(false) }
|
||||
var showLoad by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if(showDialog)
|
||||
if (showGroupDialog) {
|
||||
EditPlaceColorDialog(
|
||||
deleteMode = true,
|
||||
onDismiss = {
|
||||
val g = Data.selected_group
|
||||
if (g != null) {
|
||||
Data.visits.reassignAllVisitedToGroup(g.key)
|
||||
Data.groups.keepOnly(g.key)
|
||||
Data.saveData()
|
||||
groups = offString
|
||||
prefs.edit { putString(keyGroup, offString) }
|
||||
}
|
||||
showGroupDialog = false
|
||||
})
|
||||
}
|
||||
|
||||
if (showRegionalDialog) {
|
||||
Dialog(
|
||||
content = {
|
||||
Column(
|
||||
@@ -298,26 +204,27 @@ fun RegionalScreen() {
|
||||
MaterialTheme.colors.background,
|
||||
RoundedCornerShape(corner = CornerSize(16.dp))
|
||||
)
|
||||
.padding(16.dp),){
|
||||
Text(style=MaterialTheme.typography.caption, text= stringResource(R.string.delete_regions))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
style = MaterialTheme.typography.caption,
|
||||
text = stringResource(R.string.delete_regions)
|
||||
)
|
||||
Button(onClick = {
|
||||
GeoLocImporter.clearStates()
|
||||
regional= selected
|
||||
prefs.edit {
|
||||
putString(
|
||||
context.getString(R.string.key_regional),
|
||||
regional
|
||||
)
|
||||
}
|
||||
showDialog=false
|
||||
}){
|
||||
Text(stringResource(R.string.ok))
|
||||
regional = offString
|
||||
prefs.edit { putString(keyRegional, offString) }
|
||||
showRegionalDialog = false
|
||||
}) {
|
||||
Text(stringResource(R.string.ok))
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismissRequest = { showDialog=false }
|
||||
onDismissRequest = { showRegionalDialog = false }
|
||||
)
|
||||
if(showLoad){
|
||||
}
|
||||
|
||||
if (showLoad) {
|
||||
Dialog(
|
||||
content = {
|
||||
CircularProgressIndicator(
|
||||
@@ -329,41 +236,185 @@ fun RegionalScreen() {
|
||||
onDismissRequest = {}
|
||||
)
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
MultiPreference(arrayOf(stringResource(R.string.on),stringResource(R.string.off)),regional) { key ->
|
||||
when (key) {
|
||||
context.getString(R.string.off) -> { showDialog=true
|
||||
selected=key
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.key_theme),
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
)
|
||||
MultiPreference(
|
||||
arrayOf(
|
||||
stringResource(R.string.system),
|
||||
stringResource(R.string.light),
|
||||
stringResource(R.string.dark)
|
||||
), theme
|
||||
) { newTheme ->
|
||||
theme = newTheme
|
||||
prefs.edit { putString(keyTheme, newTheme) }
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.key_projection),
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
MultiPreference(
|
||||
arrayOf(
|
||||
stringResource(R.string.mercator),
|
||||
stringResource(R.string.azimuthalequidistant),
|
||||
stringResource(R.string.loximuthal),
|
||||
),
|
||||
projection
|
||||
) { newProj ->
|
||||
projection = newProj
|
||||
prefs.edit { putString(keyProjection, newProj) }
|
||||
Settings.refreshProjection()
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
SettingSwitch(
|
||||
label = stringResource(R.string.key_group),
|
||||
subtitle = stringResource(R.string.key_group_desc),
|
||||
isChecked = groups == onString,
|
||||
onCheckedChange = { isChecked ->
|
||||
if (!isChecked) {
|
||||
Data.selected_group = null
|
||||
showGroupDialog = true
|
||||
} else {
|
||||
groups = onString
|
||||
prefs.edit { putString(keyGroup, onString) }
|
||||
}
|
||||
context.getString(R.string.on) -> {
|
||||
regional = key
|
||||
prefs.edit { putString(context.getString(R.string.key_regional), key) }
|
||||
showLoad=true
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
SettingSwitch(
|
||||
label = stringResource(R.string.key_regional),
|
||||
subtitle = stringResource(R.string.key_regional_desc),
|
||||
isChecked = regional == onString,
|
||||
onCheckedChange = { isChecked ->
|
||||
if (isChecked) {
|
||||
regional = onString
|
||||
prefs.edit { putString(keyRegional, onString) }
|
||||
showLoad = true
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
GeoLocImporter.importStates(context, true)
|
||||
}
|
||||
showLoad = false
|
||||
}
|
||||
} else {
|
||||
showRegionalDialog = true
|
||||
}
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
SettingSwitch(
|
||||
label = stringResource(R.string.pref_cascade_stats),
|
||||
subtitle = stringResource(R.string.pref_cascade_stats_desc),
|
||||
isChecked = cascadeStats == onString,
|
||||
onCheckedChange = { isChecked ->
|
||||
cascadeStats = if (isChecked) onString else offString
|
||||
prefs.edit { putString(keyCascadeStats, cascadeStats) }
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.pref_touch_radius),
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.pref_touch_radius_desc),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Slider(
|
||||
value = touchRadius.toFloat(),
|
||||
onValueChange = { touchRadius = it.roundToInt() },
|
||||
onValueChangeFinished = { prefs.edit { putInt(keyTouchRadius, touchRadius) } },
|
||||
valueRange = 0f..MAX_TOUCH_RADIUS.toFloat(),
|
||||
steps = MAX_TOUCH_RADIUS - 1,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.pref_touch_radius_value, touchRadius),
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
onResult = { uri -> Data.doImport(context, uri) }
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
launcher.launch(arrayOf("*/*"))
|
||||
}, modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
Text(stringResource(R.string.action_import))
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
Data.doExport(context)
|
||||
}, modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
Text(stringResource(R.string.action_export))
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
PreferenceButton(stringResource(R.string.about)) {
|
||||
if (navController.currentDestination?.route != "about")
|
||||
navController.navigate("about")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun MultiPreference(list: Array<String>, selected: String, onSelected: (String) -> Unit) {
|
||||
Column(Modifier.padding(2.dp)) {
|
||||
list.map { value ->
|
||||
Column(Modifier.padding(vertical = 2.dp)) {
|
||||
list.forEach { value ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.height(42.dp)
|
||||
.clickable { onSelected(value) }) {
|
||||
RadioButton(selected = selected == value, onClick = { onSelected(value) })
|
||||
Text(
|
||||
value, modifier = Modifier.padding(start = 8.dp),
|
||||
value, modifier = Modifier.padding(start = 12.dp),
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
)
|
||||
}
|
||||
@@ -378,4 +429,39 @@ fun PreferenceButton(text: String, onClick: () -> Unit) {
|
||||
.padding(vertical = 8.dp)) {
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingSwitch(
|
||||
label: String,
|
||||
subtitle: String? = null,
|
||||
isChecked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onCheckedChange(!isChecked) }
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text=label,
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
androidx.compose.material.Switch(
|
||||
checked = isChecked,
|
||||
onCheckedChange = onCheckedChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -32,12 +33,15 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.helcel.beans.R
|
||||
import net.helcel.beans.countries.Country
|
||||
import net.helcel.beans.countries.GeoLoc
|
||||
import net.helcel.beans.countries.GeoLoc.LocType
|
||||
import net.helcel.beans.countries.World
|
||||
import net.helcel.beans.helper.AUTO_GROUP
|
||||
import net.helcel.beans.helper.Data
|
||||
import net.helcel.beans.helper.Groups
|
||||
import net.helcel.beans.helper.Settings.isRegional
|
||||
import net.helcel.beans.helper.NO_GROUP
|
||||
import net.helcel.beans.helper.Settings
|
||||
import net.helcel.beans.helper.Theme.getContrastColor
|
||||
|
||||
private val MODE_LIST = listOf(LocType.WORLD, LocType.COUNTRY, LocType.STATE)
|
||||
@@ -46,7 +50,7 @@ private val MODE_LIST = listOf(LocType.WORLD, LocType.COUNTRY, LocType.STATE)
|
||||
fun StatsScreen(
|
||||
onExit: ()-> Unit
|
||||
) {
|
||||
val modes = if (isRegional(LocalContext.current)) MODE_LIST else MODE_LIST.take(2)
|
||||
val modes = if (Settings.isRegional(LocalContext.current)) MODE_LIST else MODE_LIST.take(2)
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
var countMode by remember { mutableStateOf(true) }
|
||||
|
||||
@@ -57,7 +61,7 @@ fun StatsScreen(
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically){
|
||||
Text(text=stringResource(R.string.action_edit), modifier = Modifier.weight(1f))
|
||||
Text(text=stringResource(R.string.action_stat), modifier = Modifier.weight(1f))
|
||||
Button(onClick = { countMode = !countMode }) {
|
||||
Text(if (countMode) "Count" else "Area")
|
||||
}
|
||||
@@ -116,26 +120,82 @@ fun StatsList(activeMode: LocType, countMode: Boolean) {
|
||||
@Composable
|
||||
fun StatsRow(group: Groups.Group, activeMode: LocType, countMode: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val isCascadeStats = remember { Settings.isCascadeStats(context) }
|
||||
val visits by Data.visits.visitsFlow.collectAsState()
|
||||
|
||||
val visited = remember(group, activeMode) {
|
||||
Data.visits.getVisitedByValue(group.key)
|
||||
val continents = remember { World.WWW.children.toList() }
|
||||
val countries = remember {
|
||||
World.WWW.children.flatMap {
|
||||
if (it is Country) listOf(it) else it.children
|
||||
}
|
||||
}
|
||||
|
||||
val count = when (activeMode) {
|
||||
LocType.WORLD -> World.WWW.children.filter { it.code in visited }.size
|
||||
LocType.COUNTRY -> World.WWW.children.flatMap { it.children.filter { c -> c.code in visited } }.size
|
||||
LocType.STATE -> World.WWW.children.flatMap { itc->itc.children.flatMap { it.children.filter { it.code in visited } } }.size
|
||||
else -> 0
|
||||
val visitedItems = remember(group, activeMode, isCascadeStats, visits) {
|
||||
val list = when (activeMode) {
|
||||
LocType.WORLD -> continents
|
||||
LocType.COUNTRY -> countries
|
||||
LocType.STATE -> countries.flatMap { it.children }
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
fun getExplicitColor(l: GeoLoc): Int? {
|
||||
val c = visits.getOrDefault(l.code, 0)
|
||||
return if (c != NO_GROUP && c != AUTO_GROUP) c else null
|
||||
}
|
||||
|
||||
fun getEffectiveGroup(l: GeoLoc): Int {
|
||||
getExplicitColor(l)?.let { return it }
|
||||
|
||||
if (isCascadeStats) {
|
||||
// Parent -> Child (closest explicit ancestor)
|
||||
val country = countries.find { it.children.contains(l) }
|
||||
val countryColor = country?.let { getExplicitColor(it) }
|
||||
if (countryColor != null) return countryColor
|
||||
|
||||
val continent = continents.find {
|
||||
it.children.contains(l) || (country != null && it.children.contains(country))
|
||||
}
|
||||
val continentColor = continent?.let { getExplicitColor(it) }
|
||||
if (continentColor != null) return continentColor
|
||||
|
||||
val worldColor = getExplicitColor(World.WWW)
|
||||
if (worldColor != null) return worldColor
|
||||
|
||||
// Child -> Parent (most common explicit descendant)
|
||||
val descendants = mutableListOf<GeoLoc>()
|
||||
fun collect(curr: GeoLoc) {
|
||||
curr.children.forEach {
|
||||
descendants.add(it)
|
||||
collect(it)
|
||||
}
|
||||
}
|
||||
collect(l)
|
||||
val descendantColors = descendants.mapNotNull { getExplicitColor(it) }
|
||||
if (descendantColors.isNotEmpty()) {
|
||||
return descendantColors.groupBy { it }.maxByOrNull { it.value.size }?.key ?: AUTO_GROUP
|
||||
}
|
||||
} else {
|
||||
if (l.type == LocType.WORLD || l.type == LocType.COUNTRY) {
|
||||
val descendants = mutableListOf<GeoLoc>()
|
||||
fun collect(curr: GeoLoc) {
|
||||
curr.children.forEach { descendants.add(it); collect(it) }
|
||||
}
|
||||
collect(l)
|
||||
val dc = descendants.mapNotNull { getExplicitColor(it) }
|
||||
if (dc.isNotEmpty()) return dc.groupBy { it }.maxByOrNull { it.value.size }?.key ?: AUTO_GROUP
|
||||
}
|
||||
}
|
||||
|
||||
return AUTO_GROUP
|
||||
}
|
||||
|
||||
list.filter { getEffectiveGroup(it) == group.key }
|
||||
}
|
||||
|
||||
val area = when (activeMode) {
|
||||
LocType.WORLD -> World.WWW.children.filter { it.code in visited }.sumOf { it.area }
|
||||
LocType.COUNTRY -> World.WWW.children.flatMap { it.children.filter { c -> c.code in visited } }.sumOf { it.area }
|
||||
LocType.STATE -> World.WWW.children.flatMap { it.children.flatMap { it.children.filter { it.code in visited } } }.sumOf { it.area }
|
||||
else -> 0
|
||||
}
|
||||
val count = visitedItems.size
|
||||
val area = visitedItems.sumOf { it.area.toLong() }
|
||||
|
||||
val displayValue = if (countMode) count.toString() else context.getString(R.string.number_with_unit, area, "km²")
|
||||
val displayValue = if (countMode) count.toString() else stringResource(R.string.number_with_unit, area.toInt(), "km²")
|
||||
|
||||
val backgroundColor = group.color.color
|
||||
val textColor = getContrastColor(backgroundColor)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package net.helcel.beans.activity.sub
|
||||
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -54,27 +55,89 @@ fun EditPlaceScreenPreview(){
|
||||
EditPlaceScreen(Group.EEE)
|
||||
}
|
||||
|
||||
fun syncVisited(loc: GeoLoc?=World.WWW){
|
||||
/**
|
||||
* Applies a place straight away where a single group makes the choice obvious,
|
||||
* as tapping the checkbox does. Returns false when the group still has to be
|
||||
* picked from the colour dialog.
|
||||
*/
|
||||
fun applyDirectVisit(ctx: Context, loc: GeoLoc): Boolean {
|
||||
Data.selected_geoloc = loc
|
||||
Data.selected_group = null
|
||||
if (Data.groups.size() != 1 || !Settings.isSingleGroup(ctx)) return false
|
||||
val current = Data.visits.getVisited(loc)
|
||||
Data.visits.setVisited(
|
||||
loc,
|
||||
if (current == NO_GROUP || current == AUTO_GROUP) Data.groups.getUniqueEntry()!!.key
|
||||
else if (loc.children.any { Data.visits.getVisited(it) != NO_GROUP }) AUTO_GROUP
|
||||
else NO_GROUP,
|
||||
)
|
||||
syncVisited()
|
||||
Data.saveData()
|
||||
Data.selected_geoloc = null
|
||||
return true
|
||||
}
|
||||
|
||||
/** Stores whatever the colour dialog came back with. */
|
||||
fun commitVisitDialog(cleared: Boolean) {
|
||||
if (cleared) {
|
||||
Data.visits.setVisited(Data.selected_geoloc, NO_GROUP)
|
||||
syncVisited()
|
||||
Data.saveData()
|
||||
}
|
||||
if (Data.selected_group != null && Data.selected_geoloc != null) {
|
||||
Data.visits.setVisited(Data.selected_geoloc, Data.selected_group!!.key)
|
||||
syncVisited()
|
||||
Data.saveData()
|
||||
}
|
||||
Data.selected_geoloc = null
|
||||
Data.selected_group = null
|
||||
}
|
||||
|
||||
fun syncVisited(loc: GeoLoc?=World.WWW): Boolean {
|
||||
var changed = false
|
||||
loc?.children?.forEach { tt ->
|
||||
tt.children.forEach {itc->
|
||||
if(Data.visits.getVisited(itc) in listOf(AUTO_GROUP,NO_GROUP)) {
|
||||
if(itc.children.any { itcc -> Data.visits.getVisited(itcc) != NO_GROUP })
|
||||
Data.visits.setVisited(itc, AUTO_GROUP)
|
||||
val newState = if(itc.children.any { c -> Data.visits.getVisited(c) != NO_GROUP })
|
||||
AUTO_GROUP
|
||||
else
|
||||
Data.visits.setVisited(itc, NO_GROUP)
|
||||
NO_GROUP
|
||||
|
||||
if (Data.visits.getVisited(itc) != newState) {
|
||||
Data.visits.setVisited(itc, newState)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if(Data.visits.getVisited(tt) in listOf(AUTO_GROUP,NO_GROUP)) {
|
||||
if(tt.children.any { itc -> Data.visits.getVisited(itc) != NO_GROUP })
|
||||
Data.visits.setVisited(tt, AUTO_GROUP)
|
||||
val newState = if(tt.children.any { itc -> Data.visits.getVisited(itc) != NO_GROUP })
|
||||
AUTO_GROUP
|
||||
else
|
||||
Data.visits.setVisited(tt, NO_GROUP)
|
||||
NO_GROUP
|
||||
|
||||
if (Data.visits.getVisited(tt) != newState) {
|
||||
Data.visits.setVisited(tt, newState)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sync World from Continents
|
||||
if (loc != null && Data.visits.getVisited(loc) in listOf(AUTO_GROUP, NO_GROUP)) {
|
||||
val newState = if(loc.children.any { Data.visits.getVisited(it) != NO_GROUP })
|
||||
AUTO_GROUP
|
||||
else
|
||||
NO_GROUP
|
||||
if (Data.visits.getVisited(loc) != newState) {
|
||||
Data.visits.setVisited(loc, newState)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
||||
val visits by Data.visits.visitsFlow.collectAsState()
|
||||
var showEdit by remember { mutableStateOf(false) }
|
||||
val tabs : SnapshotStateList<GeoLoc> = remember { mutableStateListOf(loc) }
|
||||
val ctx = LocalContext.current
|
||||
@@ -84,7 +147,12 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
||||
selectedTab = tabs.lastIndex
|
||||
}
|
||||
SideEffect {
|
||||
syncVisited()
|
||||
// visits is used to trigger sync whenever data changes
|
||||
if (visits.isNotEmpty() || true) {
|
||||
if (syncVisited()) {
|
||||
Data.saveData()
|
||||
}
|
||||
}
|
||||
}
|
||||
BackHandler {
|
||||
if (tabs.size > 1) tabs.removeAt(tabs.lastIndex)
|
||||
@@ -93,39 +161,25 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
||||
if(showEdit)
|
||||
EditPlaceDialog(false) {
|
||||
showEdit = false
|
||||
if (it) {
|
||||
Data.visits.setVisited(Data.selected_geoloc, NO_GROUP)
|
||||
Data.saveData()
|
||||
|
||||
if (Data.selected_geoloc!=null && Data.selected_geoloc!!.children.any { itc-> Data.visits.getVisited(itc) != NO_GROUP }) {
|
||||
Data.clearing_geoloc = Data.selected_geoloc
|
||||
}
|
||||
}
|
||||
if (Data.selected_group != null && Data.selected_geoloc != null) {
|
||||
Data.visits.setVisited(Data.selected_geoloc, Data.selected_group!!.key)
|
||||
Data.saveData()
|
||||
}
|
||||
Data.selected_geoloc = null
|
||||
Data.selected_group = null
|
||||
commitVisitDialog(it)
|
||||
}
|
||||
|
||||
Column {
|
||||
val currentTab = tabs.getOrNull(selectedTab) ?: return@Column
|
||||
TabRow(
|
||||
selectedTabIndex = min(tabs.lastIndex, selectedTab),
|
||||
) {
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = {
|
||||
while (tabs.size > index + 1)
|
||||
tabs.removeAt(tabs.lastIndex)
|
||||
},
|
||||
text = { Text(tab.fullName) }
|
||||
)
|
||||
}
|
||||
TabRow(
|
||||
selectedTabIndex = min(tabs.lastIndex, selectedTab),
|
||||
) {
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = {
|
||||
while (tabs.size > index + 1)
|
||||
tabs.removeAt(tabs.lastIndex)
|
||||
},
|
||||
text = { Text(tab.fullName) }
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
@@ -137,20 +191,7 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
||||
tabs.add(loc)
|
||||
}
|
||||
}, {
|
||||
Data.selected_geoloc = loc
|
||||
if (Data.groups.size() == 1 && Settings.isSingleGroup(ctx)) {
|
||||
Data.visits.setVisited(Data.selected_geoloc,
|
||||
if (it != ToggleableState.On) Data.groups.getUniqueEntry()!!.key
|
||||
else if(Data.selected_geoloc?.children?.any{ itc->
|
||||
Data.visits.getVisited(itc)!= NO_GROUP } == true) AUTO_GROUP
|
||||
else NO_GROUP
|
||||
)
|
||||
Data.selected_group = null
|
||||
} else {
|
||||
Data.selected_group = null
|
||||
showEdit=true
|
||||
}
|
||||
|
||||
if (!applyDirectVisit(ctx, loc)) showEdit = true
|
||||
})
|
||||
|
||||
}
|
||||
@@ -164,7 +205,7 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
||||
fun GeoLocRow(
|
||||
loc: GeoLoc,
|
||||
onClick: () -> Unit,
|
||||
onCheckedChange: (ToggleableState) -> Unit
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
val visits by Data.visits.visitsFlow.collectAsState()
|
||||
val checked by remember(visits, loc) {
|
||||
@@ -203,7 +244,7 @@ fun GeoLocRow(
|
||||
|
||||
TriStateCheckbox(
|
||||
state = checked,
|
||||
onClick= { onCheckedChange(checked) },
|
||||
onClick = onToggle,
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = color,
|
||||
),
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package net.helcel.beans.activity.sub
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.mikepenz.aboutlibraries.ui.compose.DefaultChipColors
|
||||
import com.mikepenz.aboutlibraries.ui.compose.DefaultLibraryColors
|
||||
import com.mikepenz.aboutlibraries.ui.compose.android.rememberLibraries
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||
import net.helcel.beans.R
|
||||
import net.helcel.beans.activity.SysTheme
|
||||
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun LicenseScreen() {
|
||||
val libraries = rememberLibraries(R.raw.aboutlibraries)
|
||||
SysTheme {
|
||||
LibrariesContainer(
|
||||
libraries = libraries.value,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
colors = DefaultLibraryColors(
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
contentColor = MaterialTheme.colors.onBackground,
|
||||
licenseChipColors = DefaultChipColors(
|
||||
containerColor = MaterialTheme.colors.primary,
|
||||
contentColor = MaterialTheme.colors.onPrimary,
|
||||
),
|
||||
versionChipColors = DefaultChipColors(
|
||||
containerColor = MaterialTheme.colors.secondary,
|
||||
contentColor = MaterialTheme.colors.onSecondary,
|
||||
),
|
||||
fundingChipColors = DefaultChipColors(
|
||||
containerColor = MaterialTheme.colors.secondary,
|
||||
contentColor = MaterialTheme.colors.onSecondary,
|
||||
),
|
||||
dialogConfirmButtonColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package net.helcel.beans.activity.sub
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import net.helcel.beans.R
|
||||
import net.helcel.beans.activity.SysTheme
|
||||
import net.helcel.beans.countries.GeoLoc
|
||||
import net.helcel.beans.countries.GeoLocTree
|
||||
import net.helcel.beans.helper.AUTO_GROUP
|
||||
import net.helcel.beans.helper.Data
|
||||
import net.helcel.beans.helper.NO_GROUP
|
||||
|
||||
private class PickRow(val loc: GeoLoc, val depth: Int)
|
||||
|
||||
/**
|
||||
* Turns the shapes a tap landed near into an indented list.
|
||||
*
|
||||
* Each candidate brings its continent and country along, so a tap that is
|
||||
* ambiguous between two regions can still be answered with "the whole country"
|
||||
* or "the whole continent" instead of only the regions themselves.
|
||||
*/
|
||||
private fun rowsFor(candidates: List<GeoLoc>): List<PickRow> {
|
||||
val rows = LinkedHashMap<String, PickRow>()
|
||||
candidates.forEach { candidate ->
|
||||
GeoLocTree.chain(candidate).forEachIndexed { depth, loc ->
|
||||
rows.getOrPut(loc.code) { PickRow(loc, depth) }
|
||||
}
|
||||
}
|
||||
return rows.values.toList()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapPickDialog(
|
||||
candidates: List<GeoLoc>,
|
||||
onPick: (GeoLoc) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val visits by Data.visits.visitsFlow.collectAsState()
|
||||
val rows = remember(candidates) { rowsFor(candidates) }
|
||||
|
||||
SysTheme {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
MaterialTheme.colors.background,
|
||||
RoundedCornerShape(corner = CornerSize(16.dp)),
|
||||
)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
text = stringResource(R.string.select_place),
|
||||
)
|
||||
Text(
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
text = stringResource(R.string.select_place_sub),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 360.dp),
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
items(rows, key = { it.loc.code }) { row ->
|
||||
val group = visits.getOrElse(row.loc.code) { NO_GROUP }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onPick(row.loc) }
|
||||
.background(
|
||||
Color(88, 88, 88, 88),
|
||||
RoundedCornerShape(corner = CornerSize(16.dp)),
|
||||
)
|
||||
.padding(
|
||||
start = 8.dp + 16.dp * row.depth,
|
||||
top = 8.dp,
|
||||
end = 8.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.background(
|
||||
if (group == NO_GROUP || group == AUTO_GROUP) {
|
||||
MaterialTheme.colors.onBackground
|
||||
} else {
|
||||
Color(
|
||||
Data.groups.getGroupFromKey(group).color.color
|
||||
)
|
||||
},
|
||||
CircleShape,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
style = MaterialTheme.typography.body2,
|
||||
text = row.loc.fullName,
|
||||
// Long names wrap inside the row rather
|
||||
// than running off the end of it.
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ object GeoLocImporter {
|
||||
|
||||
fun importStates(ctx: Context, force: Boolean = false) {
|
||||
if (!Settings.isRegional(ctx) and !force) {
|
||||
GeoLocTree.rebuild()
|
||||
return
|
||||
}
|
||||
val fs = BufferedReader(InputStreamReader(ctx.assets.open("geoloc_state.txt")))
|
||||
@@ -26,6 +27,7 @@ object GeoLocImporter {
|
||||
}
|
||||
}
|
||||
}
|
||||
GeoLocTree.rebuild()
|
||||
}
|
||||
|
||||
fun clearStates() {
|
||||
@@ -39,6 +41,7 @@ object GeoLocImporter {
|
||||
}
|
||||
country.children.clear()
|
||||
}
|
||||
GeoLocTree.rebuild()
|
||||
Data.saveData()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package net.helcel.beans.countries
|
||||
|
||||
/**
|
||||
* Code to place lookup, plus the chain of parents above a place.
|
||||
*
|
||||
* Regions only exist once [GeoLocImporter] has read them, so this is rebuilt
|
||||
* whenever that set changes rather than derived once and cached forever.
|
||||
*/
|
||||
object GeoLocTree {
|
||||
|
||||
private var index: Map<String, GeoLoc> = emptyMap()
|
||||
private var parents: Map<String, GeoLoc> = emptyMap()
|
||||
|
||||
fun rebuild() {
|
||||
val byCode = HashMap<String, GeoLoc>()
|
||||
val parent = HashMap<String, GeoLoc>()
|
||||
byCode[World.WWW.code] = World.WWW
|
||||
World.WWW.children.forEach { child ->
|
||||
byCode[child.code] = child
|
||||
parent[child.code] = World.WWW
|
||||
child.children.forEach { country ->
|
||||
byCode[country.code] = country
|
||||
parent[country.code] = child
|
||||
country.children.forEach { state ->
|
||||
byCode[state.code] = state
|
||||
parent[state.code] = country
|
||||
}
|
||||
}
|
||||
}
|
||||
index = byCode
|
||||
parents = parent
|
||||
}
|
||||
|
||||
fun find(code: String): GeoLoc? {
|
||||
if (index.isEmpty()) rebuild()
|
||||
return index[code]
|
||||
}
|
||||
|
||||
/** [loc] and everything above it, world first, so it reads as a path. */
|
||||
fun chain(loc: GeoLoc): List<GeoLoc> {
|
||||
if (index.isEmpty()) rebuild()
|
||||
val path = ArrayList<GeoLoc>(4)
|
||||
var current: GeoLoc? = loc
|
||||
while (current != null && current != World.WWW) {
|
||||
path.add(current)
|
||||
current = parents[current.code]
|
||||
}
|
||||
return path.reversed()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package net.helcel.beans.countries
|
||||
|
||||
import net.helcel.beans.countries.Country.*
|
||||
|
||||
@Suppress("RedundantSuppression", "SpellCheckingInspection", "unused")
|
||||
enum class Group(override val fullName: String, override val children: Set<GeoLoc>) : GeoLoc {
|
||||
|
||||
EEE(
|
||||
|
||||
@@ -8,7 +8,6 @@ import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import net.helcel.beans.R
|
||||
import net.helcel.beans.countries.GeoLoc
|
||||
import java.util.HashMap
|
||||
@@ -23,7 +22,6 @@ object Data {
|
||||
|
||||
var selected_group : Groups.Group? = null
|
||||
var selected_geoloc: GeoLoc? = null
|
||||
var clearing_geoloc: GeoLoc? = null
|
||||
|
||||
private val groupsSerial = Groups.GroupsSerializer()
|
||||
private val visitsSerial = Visits.VisitsSerializer()
|
||||
@@ -31,7 +29,7 @@ object Data {
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
|
||||
fun loadData(ctx: Context, id:Int) {
|
||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
sharedPreferences = defaultPreferences(ctx)
|
||||
|
||||
val groupsString = sharedPreferences.getString("groups_$id",null)
|
||||
val visitsString = sharedPreferences.getString("visits_$id",null)
|
||||
@@ -43,14 +41,14 @@ object Data {
|
||||
if (groups.size() == 0) {
|
||||
groups.setGroup(DEFAULT_GROUP, "Visited",
|
||||
ContextCompat.getColor(ctx, R.color.blue).toDrawable())
|
||||
saveData()
|
||||
}
|
||||
saveData()
|
||||
}
|
||||
|
||||
fun saveData() {
|
||||
if(groups.id != visits.id) return
|
||||
val id = groups.id
|
||||
sharedPreferences.edit {
|
||||
sharedPreferences.edit(commit=true) {
|
||||
putString("groups_$id", groupsSerial.writeTo(groups))
|
||||
putString("visits_$id", visitsSerial.writeTo(visits))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package net.helcel.beans.helper
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -24,53 +25,66 @@ const val AUTO_GROUP = -1
|
||||
|
||||
|
||||
@Serializable
|
||||
class Groups(val id: Int, private val grps: HashMap<Int, Group>) {
|
||||
class Groups(val id: Int, @SerialName("grps") private val groups: HashMap<Int, Group>) {
|
||||
@kotlinx.serialization.Transient
|
||||
private val _groupsFlow = MutableStateFlow<List<Group>>(grps.values.toList())
|
||||
private val _groupsFlow = MutableStateFlow<List<Group>>(groups.values.toList())
|
||||
@kotlinx.serialization.Transient
|
||||
val groupsFlow: StateFlow<List<Group>> = _groupsFlow.asStateFlow()
|
||||
|
||||
fun setGroup(key: Int, name: String, col: ColorDrawable) {
|
||||
grps[key] = Group(key, name, col)
|
||||
_groupsFlow.value = grps.values.toList()
|
||||
val old = groups[key]
|
||||
if (old != null && old.name == name && old.color.color == col.color) return
|
||||
groups[key] = Group(key, name, col)
|
||||
updateFlow()
|
||||
}
|
||||
|
||||
fun deleteGroup(key: Int) {
|
||||
grps.remove(key)
|
||||
_groupsFlow.value = grps.values.toList()
|
||||
groups.remove(key)
|
||||
updateFlow()
|
||||
}
|
||||
|
||||
fun keepOnly(key: Int) {
|
||||
val keep = groups[key] ?: return
|
||||
groups.clear()
|
||||
groups[key] = keep
|
||||
updateFlow()
|
||||
}
|
||||
|
||||
private fun updateFlow() {
|
||||
_groupsFlow.value = groups.values.toList()
|
||||
}
|
||||
|
||||
fun getGroupFromKey(key: Int): Group {
|
||||
return grps.getOrDefault(key, EmptyGroup())
|
||||
return groups.getOrDefault(key, EmptyGroup())
|
||||
}
|
||||
|
||||
fun genKey(): Int {
|
||||
val key = rnd.nextInt()
|
||||
if (grps.containsKey(key) || key in listOf(NO_GROUP, DEFAULT_GROUP, AUTO_GROUP)) return genKey()
|
||||
if (groups.containsKey(key) || key in listOf(NO_GROUP, DEFAULT_GROUP, AUTO_GROUP)) return genKey()
|
||||
return key
|
||||
}
|
||||
|
||||
fun size(): Int {
|
||||
return grps.size
|
||||
return groups.size
|
||||
}
|
||||
|
||||
fun getUniqueEntry(): Group? {
|
||||
assert(size() == 1)
|
||||
return if (grps.size == 1) {
|
||||
grps[grps.keys.first()]
|
||||
return if (groups.size == 1) {
|
||||
groups[groups.keys.first()]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getGroupFromPos(pos: Int): Pair<Int, Group> {
|
||||
if(grps.keys.isEmpty()) return Pair(NO_GROUP,Group(NO_GROUP,"-"))
|
||||
val key = grps.keys.toList()[pos]
|
||||
if(groups.keys.isEmpty()) return Pair(NO_GROUP,Group(NO_GROUP,"-"))
|
||||
val key = groups.keys.toList()[pos]
|
||||
return Pair(key, getGroupFromKey(key))
|
||||
}
|
||||
|
||||
fun findGroupPos(key: Int): Int {
|
||||
return grps.keys.toList().indexOf(key)
|
||||
return groups.keys.toList().indexOf(key)
|
||||
}
|
||||
|
||||
class EmptyGroup : Group(0, "")
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package net.helcel.beans.helper
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
/**
|
||||
* The preference file opened directly, under the name and mode androidx's
|
||||
* PreferenceManager used. Settings written by earlier versions still load, and
|
||||
* the app no longer pulls in the preference UI framework for one call.
|
||||
*/
|
||||
fun defaultPreferences(ctx: Context): SharedPreferences =
|
||||
ctx.getSharedPreferences(ctx.packageName + "_preferences", Context.MODE_PRIVATE)
|
||||
@@ -2,17 +2,26 @@ package net.helcel.beans.helper
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceManager
|
||||
import net.helcel.beans.R
|
||||
import net.helcel.beans.activity.MainScreen
|
||||
|
||||
/**
|
||||
* Default distance, in dp, around a tap that still counts as hitting a place.
|
||||
* Kept small because the radius is measured on screen: zoomed out to the whole
|
||||
* world, even a few dp reach across several countries.
|
||||
*/
|
||||
const val DEFAULT_TOUCH_RADIUS = 4
|
||||
|
||||
/** Largest tap radius the setting offers. */
|
||||
const val MAX_TOUCH_RADIUS = 48
|
||||
|
||||
object Settings {
|
||||
|
||||
private lateinit var sp: SharedPreferences
|
||||
private lateinit var mainActivity: MainScreen
|
||||
fun start(ctx: MainScreen) {
|
||||
mainActivity = ctx
|
||||
sp = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
sp = defaultPreferences(ctx)
|
||||
}
|
||||
|
||||
fun isSingleGroup(ctx: Context): Boolean {
|
||||
@@ -29,6 +38,21 @@ object Settings {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How far around a tap, in dp, the map looks for other places. Anything
|
||||
* within it is offered as a candidate instead of painting straight away.
|
||||
*/
|
||||
fun getTouchRadius(ctx: Context): Float {
|
||||
return sp.getInt(ctx.getString(R.string.key_touch_radius), DEFAULT_TOUCH_RADIUS).toFloat()
|
||||
}
|
||||
|
||||
fun isCascadeStats(ctx: Context): Boolean {
|
||||
return getBooleanValue(
|
||||
ctx,
|
||||
sp.getString(ctx.getString(R.string.key_cascade_stats), ctx.getString(R.string.off))
|
||||
)
|
||||
}
|
||||
|
||||
fun getStatPref(ctx: Context): String? {
|
||||
return sp.getString(
|
||||
ctx.getString(R.string.key_stats),
|
||||
|
||||
@@ -19,24 +19,25 @@ class Visits(val id: Int, private val locs: HashMap<String, Int>) {
|
||||
val visitsFlow: StateFlow<Map<String,Int>> = _visitsFlow
|
||||
|
||||
fun setVisited(key: GeoLoc?, b: Int) {
|
||||
if (key == null)
|
||||
if (key == null || locs[key.code] == b)
|
||||
return
|
||||
_visitsFlow.value = _visitsFlow.value.toMutableMap().apply {
|
||||
this[key.code] = b
|
||||
}
|
||||
locs[key.code] = b
|
||||
updateFlow()
|
||||
}
|
||||
|
||||
private fun updateFlow() {
|
||||
_visitsFlow.value = locs.toMap()
|
||||
}
|
||||
|
||||
fun deleteVisited(key: Int) {
|
||||
val keysToDelete = locs
|
||||
.filter { it.value == key }
|
||||
.map { it.key }
|
||||
_visitsFlow.value = _visitsFlow.value.toMutableMap().apply {
|
||||
keysToDelete.forEach { this.remove(it)}
|
||||
}
|
||||
if (keysToDelete.isEmpty()) return
|
||||
keysToDelete.forEach {
|
||||
locs.remove(it)
|
||||
}
|
||||
updateFlow()
|
||||
}
|
||||
|
||||
fun getVisited(key: GeoLoc): Int {
|
||||
@@ -60,13 +61,19 @@ class Visits(val id: Int, private val locs: HashMap<String, Int>) {
|
||||
}
|
||||
|
||||
fun reassignAllVisitedToGroup(group: Int) {
|
||||
var changed = false
|
||||
val keys = locs.filter { (_, grp) ->
|
||||
grp !in listOf(NO_GROUP, AUTO_GROUP)
|
||||
}.keys
|
||||
keys.forEach {
|
||||
locs[it] = group
|
||||
if (locs[it] != group) {
|
||||
locs[it] = group
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
updateFlow()
|
||||
}
|
||||
_visitsFlow.value = locs
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package net.helcel.beans.map
|
||||
|
||||
import android.content.Context
|
||||
import net.helcel.beans.R
|
||||
import net.helcel.beans.helper.defaultPreferences
|
||||
|
||||
/** Picks the map asset matching the projection the user chose. */
|
||||
object MapAssets {
|
||||
|
||||
fun assetFor(ctx: Context): String {
|
||||
val preferences = defaultPreferences(ctx)
|
||||
return when (
|
||||
preferences.getString(
|
||||
ctx.getString(R.string.key_projection),
|
||||
ctx.getString(R.string.mercator),
|
||||
)
|
||||
) {
|
||||
ctx.getString(R.string.azimuthalequidistant) -> "aeqd01.bmap"
|
||||
ctx.getString(R.string.loximuthal) -> "loxim01.bmap"
|
||||
else -> "webmercator01.bmap"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package net.helcel.beans.map
|
||||
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import java.io.InputStream
|
||||
|
||||
private const val VERSION = 2
|
||||
|
||||
/**
|
||||
* Reads the packed map assets produced by `packmap.js`.
|
||||
*
|
||||
* The maps used to ship as SVG, which spends around four megabytes per
|
||||
* projection spelling coordinates out in decimal ASCII inside XML. The packed
|
||||
* form says the same thing in a fraction of that, and reading it is a straight
|
||||
* scan over a byte array instead of an XML parse with a float scanner on top.
|
||||
*
|
||||
* The format is documented in `packmap.js`; the two have to agree.
|
||||
*/
|
||||
object MapReader {
|
||||
|
||||
fun read(input: InputStream): MapWorld {
|
||||
val reader = ByteReader(input.readBytes())
|
||||
reader.readHeader()
|
||||
val width = reader.unsigned() * reader.scale
|
||||
val height = reader.unsigned() * reader.scale
|
||||
val count = reader.unsigned()
|
||||
val shapes = ArrayList<MapShape>(count)
|
||||
repeat(count) { shapes.add(reader.shape()) }
|
||||
return MapWorld(width, height, shapes)
|
||||
}
|
||||
}
|
||||
|
||||
private class ByteReader(private val data: ByteArray) {
|
||||
|
||||
private var pos = 0
|
||||
|
||||
/** One grid step in user units, read from the header. */
|
||||
var scale = 1f
|
||||
private set
|
||||
|
||||
fun readHeader() {
|
||||
require(
|
||||
data.size > 5 &&
|
||||
data[0] == 'B'.code.toByte() && data[1] == 'M'.code.toByte() &&
|
||||
data[2] == 'A'.code.toByte() && data[3] == 'P'.code.toByte()
|
||||
) { "not a packed map" }
|
||||
require(data[4].toInt() == VERSION) { "unsupported packed map version ${data[4]}" }
|
||||
pos = 5
|
||||
scale = 1f / unsigned()
|
||||
}
|
||||
|
||||
fun unsigned(): Int {
|
||||
var result = 0
|
||||
var shift = 0
|
||||
while (true) {
|
||||
val byte = data[pos++].toInt()
|
||||
result = result or ((byte and 0x7F) shl shift)
|
||||
if (byte and 0x80 == 0) return result
|
||||
shift += 7
|
||||
}
|
||||
}
|
||||
|
||||
/** Zigzag, because deltas run both ways and are almost always tiny. */
|
||||
fun signed(): Int {
|
||||
val value = unsigned()
|
||||
return if (value and 1 == 1) -((value ushr 1) + 1) else value ushr 1
|
||||
}
|
||||
|
||||
fun flag(): Boolean = data[pos++].toInt() != 0
|
||||
|
||||
fun text(): String {
|
||||
val length = unsigned()
|
||||
val value = String(data, pos, length, Charsets.UTF_8)
|
||||
pos += length
|
||||
return value
|
||||
}
|
||||
|
||||
fun shape(): MapShape {
|
||||
val code = text()
|
||||
val isState = flag()
|
||||
val ringCount = unsigned()
|
||||
|
||||
val rings = ArrayList<FloatArray>(ringCount)
|
||||
var nonZero: Path? = null
|
||||
var evenOdd: Path? = null
|
||||
var minX = Float.MAX_VALUE
|
||||
var minY = Float.MAX_VALUE
|
||||
var maxX = -Float.MAX_VALUE
|
||||
var maxY = -Float.MAX_VALUE
|
||||
|
||||
repeat(ringCount) {
|
||||
val points = unsigned()
|
||||
val useEvenOdd = flag()
|
||||
val ring = FloatArray(points * 2)
|
||||
// Each point is a delta from the one before it, the first from the
|
||||
// origin, so the whole ring decodes in one pass.
|
||||
var x = 0
|
||||
var y = 0
|
||||
for (i in 0 until points) {
|
||||
x += signed()
|
||||
y += signed()
|
||||
val fx = x * scale
|
||||
val fy = y * scale
|
||||
ring[i * 2] = fx
|
||||
ring[i * 2 + 1] = fy
|
||||
if (fx < minX) minX = fx
|
||||
if (fx > maxX) maxX = fx
|
||||
if (fy < minY) minY = fy
|
||||
if (fy > maxY) maxY = fy
|
||||
}
|
||||
rings.add(ring)
|
||||
|
||||
val path = if (useEvenOdd) {
|
||||
evenOdd ?: Path().also { it.fillType = Path.FillType.EVEN_ODD; evenOdd = it }
|
||||
} else {
|
||||
nonZero ?: Path().also { nonZero = it }
|
||||
}
|
||||
path.moveTo(ring[0], ring[1])
|
||||
var i = 2
|
||||
while (i < ring.size) {
|
||||
path.lineTo(ring[i], ring[i + 1])
|
||||
i += 2
|
||||
}
|
||||
path.close()
|
||||
}
|
||||
|
||||
return MapShape(
|
||||
code, isState, rings,
|
||||
RectF(minX, minY, maxX, maxY),
|
||||
nonZero, evenOdd,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package net.helcel.beans.map
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.RectF
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Draws a [MapWorld] onto a canvas through a pan/zoom transform. */
|
||||
class MapRenderer(private val map: MapWorld) {
|
||||
|
||||
private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
}
|
||||
private val inverse = Matrix()
|
||||
private val values = FloatArray(9)
|
||||
private val visible = RectF()
|
||||
|
||||
/** False when the border is too faint to be worth a draw call at all. */
|
||||
private var strokeVisible = true
|
||||
|
||||
/**
|
||||
* Paints the map into [canvas], mapping user units through [transform].
|
||||
* Shapes outside the canvas are skipped, which is what makes drawing cheap
|
||||
* once the map is zoomed in.
|
||||
*/
|
||||
fun draw(canvas: Canvas, transform: Matrix, style: MapStyle) {
|
||||
// SRC rather than blended: the bitmap is reused between renders.
|
||||
canvas.drawColor(style.background, PorterDuff.Mode.SRC)
|
||||
if (!transform.invert(inverse)) return
|
||||
visible.set(0f, 0f, canvas.width.toFloat(), canvas.height.toFloat())
|
||||
inverse.mapRect(visible)
|
||||
|
||||
// Widths are given in device pixels, so undo the zoom the canvas is
|
||||
// about to apply to them, then let them grow slowly with it.
|
||||
transform.getValues(values)
|
||||
val scale = values[Matrix.MSCALE_X]
|
||||
val perPixel = 1f / scale
|
||||
val fitted = min(canvas.width / map.width, canvas.height / map.height)
|
||||
val zoom = (scale / fitted).coerceAtLeast(1f)
|
||||
val regionPx = strokeWidthAt(zoom, REGION_STROKE_STOPS)
|
||||
val countryPx = strokeWidthAt(zoom, COUNTRY_STROKE_STOPS)
|
||||
|
||||
canvas.save()
|
||||
canvas.concat(transform)
|
||||
setStroke(style.background, if (style.regional) regionPx else countryPx, perPixel)
|
||||
for ((code, color) in style.fills) {
|
||||
val shape = map.byCode[code] ?: continue
|
||||
if (!RectF.intersects(shape.bounds, visible)) continue
|
||||
fill.color = color
|
||||
drawShape(canvas, shape.nonZero, true)
|
||||
drawShape(canvas, shape.evenOdd, true)
|
||||
}
|
||||
|
||||
// With regions on, the country outlines come back on top as the thicker
|
||||
// borders between them.
|
||||
if (style.regional) {
|
||||
setStroke(style.background, countryPx, perPixel)
|
||||
for (shape in map.countries) {
|
||||
if (!RectF.intersects(shape.bounds, visible)) continue
|
||||
drawShape(canvas, shape.nonZero, false)
|
||||
drawShape(canvas, shape.evenOdd, false)
|
||||
}
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a border width given in device pixels.
|
||||
*
|
||||
* Anything thinner than a pixel cannot be drawn as such: the rasteriser
|
||||
* turns it into a one pixel hairline at full strength, which is why every
|
||||
* border below that width used to come out the same weight no matter how
|
||||
* far out the map was. Below a pixel the width is carried as opacity
|
||||
* instead, so a quarter-pixel border reads as a quarter as dark.
|
||||
*/
|
||||
private fun setStroke(color: Int, devicePx: Float, perPixel: Float) {
|
||||
strokeVisible = devicePx > 0.004f
|
||||
if (!strokeVisible) return
|
||||
stroke.color = color
|
||||
if (devicePx < 1f) {
|
||||
stroke.strokeWidth = perPixel
|
||||
stroke.alpha = (devicePx * 255f).roundToInt().coerceIn(0, 255)
|
||||
} else {
|
||||
stroke.strokeWidth = perPixel * devicePx
|
||||
stroke.alpha = 255
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawShape(canvas: Canvas, path: Path?, filled: Boolean) {
|
||||
if (path == null) return
|
||||
if (filled) canvas.drawPath(path, fill)
|
||||
if (strokeVisible) canvas.drawPath(path, stroke)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package net.helcel.beans.map
|
||||
|
||||
import android.content.Context
|
||||
import net.helcel.beans.countries.GeoLoc
|
||||
import net.helcel.beans.countries.World
|
||||
import net.helcel.beans.helper.AUTO_GROUP
|
||||
import net.helcel.beans.helper.Data
|
||||
import net.helcel.beans.helper.NO_GROUP
|
||||
import net.helcel.beans.helper.Settings
|
||||
import kotlin.math.ln
|
||||
|
||||
/**
|
||||
* Border width in device pixels at each zoom, interpolated in between.
|
||||
*
|
||||
* Zoom counts how far in the map is from its fitted size, so 1 is the whole
|
||||
* world on screen and 64 is as far in as it goes. The stops step by fours and
|
||||
* are interpolated on a log axis, so the weight changes evenly as you pinch
|
||||
* rather than in a rush at one end.
|
||||
*
|
||||
* Region borders start at nothing on purpose: a sub-national border says very
|
||||
* little with the whole world in view, and drawing three and a half thousand of
|
||||
* them only muddies the land.
|
||||
*
|
||||
* zoom 1x 4x 16x 64x
|
||||
* region 0.0 0.5 1.0 2.0
|
||||
* country 1.0 1.5 3.0 6.0
|
||||
*/
|
||||
val STROKE_ZOOM_STOPS = floatArrayOf(1f, 4f, 16f, 64f)
|
||||
|
||||
val REGION_STROKE_STOPS = floatArrayOf(0f, 0.5f, 1f, 2f)
|
||||
|
||||
val COUNTRY_STROKE_STOPS = floatArrayOf(1f, 1.5f, 3f, 6f)
|
||||
|
||||
/** The width for [zoom], straight-line between the stops on a log axis. */
|
||||
fun strokeWidthAt(zoom: Float, widths: FloatArray): Float {
|
||||
if (zoom <= STROKE_ZOOM_STOPS.first()) return widths.first()
|
||||
for (i in 1 until STROKE_ZOOM_STOPS.size) {
|
||||
if (zoom <= STROKE_ZOOM_STOPS[i]) {
|
||||
val low = STROKE_ZOOM_STOPS[i - 1]
|
||||
val t = ln(zoom / low) / ln(STROKE_ZOOM_STOPS[i] / low)
|
||||
return widths[i - 1] + t * (widths[i] - widths[i - 1])
|
||||
}
|
||||
}
|
||||
return widths.last()
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour every shape is drawn with, resolved once per render.
|
||||
*
|
||||
* This replaces the stylesheet the map used to be rendered through. A place
|
||||
* inherits the colour of its parent, so painting a whole continent still shows
|
||||
* up on each country inside it, and [fills] ends up holding exactly the shapes
|
||||
* that get drawn — which is also exactly what a tap may land on.
|
||||
*/
|
||||
class MapStyle(
|
||||
val regional: Boolean,
|
||||
val land: Int,
|
||||
val background: Int,
|
||||
val fills: Map<String, Int>,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
fun build(ctx: Context, land: Int, background: Int): MapStyle {
|
||||
val regional = Settings.isRegional(ctx)
|
||||
val fills = HashMap<String, Int>()
|
||||
World.WWW.children.forEach { child ->
|
||||
if (child.type == GeoLoc.LocType.COUNTRY) {
|
||||
// A country hanging straight off the world, such as Antarctica.
|
||||
addCountry(fills, child, colorOf(World.WWW, regional), regional, land)
|
||||
} else {
|
||||
val continent = colorOf(child, regional) ?: colorOf(World.WWW, regional)
|
||||
child.children.forEach { addCountry(fills, it, continent, regional, land) }
|
||||
}
|
||||
}
|
||||
return MapStyle(regional, land, background, fills)
|
||||
}
|
||||
|
||||
private fun addCountry(
|
||||
fills: HashMap<String, Int>,
|
||||
country: GeoLoc,
|
||||
inherited: Int?,
|
||||
regional: Boolean,
|
||||
land: Int,
|
||||
) {
|
||||
val color = colorOf(country, regional) ?: inherited
|
||||
// Regions are only drawn for countries that actually have some; the
|
||||
// rest keep their outline filled so the map stays complete.
|
||||
if (regional && country.children.isNotEmpty()) {
|
||||
country.children.forEach { state ->
|
||||
fills[state.code] = colorOf(state, regional) ?: color ?: land
|
||||
}
|
||||
} else {
|
||||
fills[country.code] = color ?: land
|
||||
}
|
||||
}
|
||||
|
||||
/** The group colour assigned to [loc], or `null` when it has none of its own. */
|
||||
private fun colorOf(loc: GeoLoc, regional: Boolean): Int? {
|
||||
val key = Data.visits.getVisited(loc)
|
||||
val group = Data.groups.getGroupFromKey(key)
|
||||
if (group.key != NO_GROUP) return group.color.color
|
||||
// A place marked automatically stands in for its children. That only
|
||||
// needs a colour of its own where the children are not drawn, and
|
||||
// never on a continent, which would swallow the whole map.
|
||||
val isParent = loc.type == GeoLoc.LocType.GROUP ||
|
||||
loc.type == GeoLoc.LocType.CUSTOM_GROUP ||
|
||||
loc.type == GeoLoc.LocType.WORLD
|
||||
if (key == AUTO_GROUP && !regional && !isParent) {
|
||||
return Data.groups.getGroupFromPos(0).second.color.color
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package net.helcel.beans.map
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.SystemClock
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.ScaleGestureDetector
|
||||
import android.view.View
|
||||
import android.widget.OverScroller
|
||||
import androidx.core.graphics.createBitmap
|
||||
import kotlin.math.min
|
||||
|
||||
/** A shape a tap landed on or near, with how far away it was in user units. */
|
||||
class MapPick(val code: String, val distance: Float)
|
||||
|
||||
/** How far past the fitted map a pinch may zoom in. */
|
||||
private const val MAX_ZOOM = 64f
|
||||
|
||||
/** How much a double tap zooms in by. */
|
||||
private const val DOUBLE_TAP_ZOOM = 3f
|
||||
|
||||
/** Time a gesture has to settle before the map is redrawn sharp again. */
|
||||
private const val RENDER_DELAY_MS = 80L
|
||||
|
||||
/** How long the map may stay stretched while a gesture keeps going. */
|
||||
private const val MAX_STALE_MS = 500L
|
||||
|
||||
/**
|
||||
* Most candidates a single tap will offer. A wide radius over a crowded part of
|
||||
* the map can reach dozens of places, which is more of a list than anyone wants
|
||||
* to read; the nearest few are what the tap was plausibly aiming at.
|
||||
*/
|
||||
private const val MAX_CANDIDATES = 16
|
||||
|
||||
/**
|
||||
* The map itself: pan, pinch and tap over a [MapWorld].
|
||||
*
|
||||
* Drawing every country as vectors costs far too much to do on each frame, so a
|
||||
* render lands in an off-screen bitmap on a worker thread and gestures just move
|
||||
* that bitmap around. Once the gesture settles the map is drawn again at the new
|
||||
* zoom, which is what keeps it sharp all the way in.
|
||||
*/
|
||||
@SuppressLint("ViewConstructor")
|
||||
class MapView(context: Context) : View(context) {
|
||||
|
||||
var world: MapWorld? = null
|
||||
set(value) {
|
||||
if (field === value) return
|
||||
field = value
|
||||
renderer = value?.let(::MapRenderer)
|
||||
fitToView()
|
||||
requestRender(0L)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var style: MapStyle? = null
|
||||
set(value) {
|
||||
if (field === value) return
|
||||
field = value
|
||||
requestRender(0L)
|
||||
}
|
||||
|
||||
/** How far from a tap, in dp, other shapes still count as candidates. */
|
||||
var touchRadiusDp: Float = 4f
|
||||
|
||||
/** Called on a tap with every nearby shape, nearest first. */
|
||||
var onPick: ((List<MapPick>) -> Unit)? = null
|
||||
|
||||
@Volatile
|
||||
private var renderer: MapRenderer? = null
|
||||
|
||||
private val scroller = OverScroller(context)
|
||||
|
||||
private val transform = Matrix()
|
||||
private val inverse = Matrix()
|
||||
private val values = FloatArray(9)
|
||||
private val point = FloatArray(2)
|
||||
private var minScale = 1f
|
||||
private var maxScale = 1f
|
||||
|
||||
private val blitPaint = Paint(Paint.FILTER_BITMAP_FLAG)
|
||||
private val blit = Matrix()
|
||||
|
||||
private val lock = Any()
|
||||
private var front: Bitmap? = null
|
||||
private var back: Bitmap? = null
|
||||
private val frontMatrix = Matrix()
|
||||
private val frontValues = FloatArray(9)
|
||||
private val pendingMatrix = Matrix()
|
||||
private var pendingWidth = 0
|
||||
private var pendingHeight = 0
|
||||
@Volatile private var lastRenderAt = 0L
|
||||
|
||||
private val renderThread = HandlerThread("map-render").apply { start() }
|
||||
private val renderHandler = Handler(renderThread.looper)
|
||||
private val renderTask = Runnable { render() }
|
||||
|
||||
private val scaleDetector = ScaleGestureDetector(
|
||||
context,
|
||||
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||
val scale = currentScale()
|
||||
val target = (scale * detector.scaleFactor).coerceIn(minScale, maxScale)
|
||||
transform.postScale(target / scale, target / scale, detector.focusX, detector.focusY)
|
||||
clamp()
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private val gestureDetector = GestureDetector(
|
||||
context,
|
||||
object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onDown(e: MotionEvent): Boolean {
|
||||
scroller.forceFinished(true)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onFling(
|
||||
e1: MotionEvent?,
|
||||
e2: MotionEvent,
|
||||
velocityX: Float,
|
||||
velocityY: Float,
|
||||
): Boolean {
|
||||
val map = world ?: return false
|
||||
transform.getValues(values)
|
||||
val scale = values[Matrix.MSCALE_X]
|
||||
val x = values[Matrix.MTRANS_X].toInt()
|
||||
val y = values[Matrix.MTRANS_Y].toInt()
|
||||
val w = map.width * scale
|
||||
val h = map.height * scale
|
||||
scroller.forceFinished(true)
|
||||
scroller.fling(
|
||||
x, y, velocityX.toInt(), velocityY.toInt(),
|
||||
if (w > width) (width - w).toInt() else x, if (w > width) 0 else x,
|
||||
if (h > height) (height - h).toInt() else y, if (h > height) 0 else y,
|
||||
)
|
||||
postInvalidateOnAnimation()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScroll(
|
||||
e1: MotionEvent?,
|
||||
e2: MotionEvent,
|
||||
distanceX: Float,
|
||||
distanceY: Float,
|
||||
): Boolean {
|
||||
transform.postTranslate(-distanceX, -distanceY)
|
||||
clamp()
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
||||
pick(e.x, e.y)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDoubleTap(e: MotionEvent): Boolean {
|
||||
val scale = currentScale()
|
||||
val target = if (scale >= maxScale * 0.99f) {
|
||||
minScale
|
||||
} else {
|
||||
min(scale * DOUBLE_TAP_ZOOM, maxScale)
|
||||
}
|
||||
transform.postScale(target / scale, target / scale, e.x, e.y)
|
||||
clamp()
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
scaleDetector.onTouchEvent(event)
|
||||
gestureDetector.onTouchEvent(event)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun computeScroll() {
|
||||
if (!scroller.computeScrollOffset()) return
|
||||
transform.getValues(values)
|
||||
values[Matrix.MTRANS_X] = scroller.currX.toFloat()
|
||||
values[Matrix.MTRANS_Y] = scroller.currY.toFloat()
|
||||
transform.setValues(values)
|
||||
postInvalidateOnAnimation()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
fitToView()
|
||||
requestRender(0L)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val current = style
|
||||
canvas.drawColor(current?.background ?: 0)
|
||||
|
||||
var stale = true
|
||||
synchronized(lock) {
|
||||
val bitmap = front
|
||||
if (bitmap != null && !bitmap.isRecycled && frontMatrix.invert(blit)) {
|
||||
blit.postConcat(transform)
|
||||
canvas.drawBitmap(bitmap, blit, blitPaint)
|
||||
transform.getValues(values)
|
||||
stale = !values.contentEquals(frontValues)
|
||||
}
|
||||
}
|
||||
// Scheduling only ever happens from here or from a setter, both on the
|
||||
// main thread, so a render never starts while its bitmap is on screen.
|
||||
if (stale) {
|
||||
val overdue = SystemClock.uptimeMillis() - lastRenderAt > MAX_STALE_MS
|
||||
requestRender(if (overdue) 0L else RENDER_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
scroller.forceFinished(true)
|
||||
renderHandler.removeCallbacks(renderTask)
|
||||
renderThread.quitSafely()
|
||||
// Dropped rather than recycled: a render may still be part way through
|
||||
// one of them, and the collector is happy to take them from here.
|
||||
synchronized(lock) {
|
||||
front = null
|
||||
back = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Centres the map in the view and works out how far it may be zoomed. */
|
||||
private fun fitToView() {
|
||||
val map = world ?: return
|
||||
if (width == 0 || height == 0 || map.width <= 0f || map.height <= 0f) return
|
||||
val scale = min(width / map.width, height / map.height)
|
||||
minScale = scale
|
||||
maxScale = scale * MAX_ZOOM
|
||||
transform.setScale(scale, scale)
|
||||
transform.postTranslate(
|
||||
(width - map.width * scale) / 2f,
|
||||
(height - map.height * scale) / 2f,
|
||||
)
|
||||
}
|
||||
|
||||
private fun currentScale(): Float {
|
||||
transform.getValues(values)
|
||||
return values[Matrix.MSCALE_X]
|
||||
}
|
||||
|
||||
/** Keeps the map from being dragged away from the viewport. */
|
||||
private fun clamp() {
|
||||
val map = world ?: return
|
||||
transform.getValues(values)
|
||||
val scale = values[Matrix.MSCALE_X]
|
||||
val w = map.width * scale
|
||||
val h = map.height * scale
|
||||
values[Matrix.MTRANS_X] = if (w <= width) {
|
||||
(width - w) / 2f
|
||||
} else {
|
||||
values[Matrix.MTRANS_X].coerceIn(width - w, 0f)
|
||||
}
|
||||
values[Matrix.MTRANS_Y] = if (h <= height) {
|
||||
(height - h) / 2f
|
||||
} else {
|
||||
values[Matrix.MTRANS_Y].coerceIn(height - h, 0f)
|
||||
}
|
||||
transform.setValues(values)
|
||||
}
|
||||
|
||||
private fun requestRender(delay: Long) {
|
||||
if (width == 0 || height == 0 || renderer == null || style == null) return
|
||||
synchronized(lock) {
|
||||
pendingMatrix.set(transform)
|
||||
pendingWidth = width
|
||||
pendingHeight = height
|
||||
}
|
||||
renderHandler.removeCallbacks(renderTask)
|
||||
renderHandler.postDelayed(renderTask, delay)
|
||||
}
|
||||
|
||||
private fun render() {
|
||||
val matrix = Matrix()
|
||||
val w: Int
|
||||
val h: Int
|
||||
synchronized(lock) {
|
||||
matrix.set(pendingMatrix)
|
||||
w = pendingWidth
|
||||
h = pendingHeight
|
||||
}
|
||||
if (w <= 0 || h <= 0) return
|
||||
val current = renderer ?: return
|
||||
val currentStyle = style ?: return
|
||||
|
||||
var target = back
|
||||
if (target == null || target.width != w || target.height != h) {
|
||||
target?.recycle()
|
||||
target = createBitmap(w, h)
|
||||
back = target
|
||||
}
|
||||
current.draw(Canvas(target), matrix, currentStyle)
|
||||
|
||||
synchronized(lock) {
|
||||
back = front
|
||||
front = target
|
||||
frontMatrix.set(matrix)
|
||||
matrix.getValues(frontValues)
|
||||
}
|
||||
lastRenderAt = SystemClock.uptimeMillis()
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
/** Reports every shape within the tap radius of ([x], [y]), nearest first. */
|
||||
private fun pick(x: Float, y: Float) {
|
||||
val map = world ?: return
|
||||
val current = style ?: return
|
||||
val callback = onPick ?: return
|
||||
if (!transform.invert(inverse)) return
|
||||
|
||||
point[0] = x
|
||||
point[1] = y
|
||||
inverse.mapPoints(point)
|
||||
val radius = touchRadiusDp * resources.displayMetrics.density / currentScale()
|
||||
|
||||
val picks = ArrayList<MapPick>()
|
||||
for (code in current.fills.keys) {
|
||||
val shape = map.byCode[code] ?: continue
|
||||
val bounds = shape.bounds
|
||||
if (point[0] < bounds.left - radius || point[0] > bounds.right + radius) continue
|
||||
if (point[1] < bounds.top - radius || point[1] > bounds.bottom + radius) continue
|
||||
val distance = shape.distanceTo(point[0], point[1])
|
||||
if (distance <= radius) picks.add(MapPick(code, distance))
|
||||
}
|
||||
if (picks.isEmpty()) return
|
||||
picks.sortBy { it.distance }
|
||||
callback(if (picks.size > MAX_CANDIDATES) picks.subList(0, MAX_CANDIDATES) else picks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package net.helcel.beans.map
|
||||
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* One named piece of map geometry: a country outline or a single region.
|
||||
*
|
||||
* The geometry is kept twice on purpose. [nonZero] and [evenOdd] are what the
|
||||
* renderer hands to the canvas, while [rings] keeps the raw points because
|
||||
* hit testing needs the distance to an edge, which a [Path] cannot give us.
|
||||
*/
|
||||
class MapShape(
|
||||
val code: String,
|
||||
val isState: Boolean,
|
||||
val rings: List<FloatArray>,
|
||||
val bounds: RectF,
|
||||
val nonZero: Path?,
|
||||
val evenOdd: Path?,
|
||||
) {
|
||||
|
||||
/** True when ([x], [y]) falls inside the shape. Holes and lakes count as outside. */
|
||||
fun contains(x: Float, y: Float): Boolean {
|
||||
if (x < bounds.left || x > bounds.right || y < bounds.top || y > bounds.bottom) return false
|
||||
// Even-odd crossing count over every ring at once: rings never overlap, so
|
||||
// an island still reads as inside and a hole still reads as outside.
|
||||
var inside = false
|
||||
for (ring in rings) {
|
||||
var j = ring.size - 2
|
||||
var i = 0
|
||||
while (i < ring.size) {
|
||||
val yi = ring[i + 1]
|
||||
val yj = ring[j + 1]
|
||||
if ((yi > y) != (yj > y)) {
|
||||
val xi = ring[i]
|
||||
val xj = ring[j]
|
||||
if (x < xi + (y - yi) * (xj - xi) / (yj - yi)) inside = !inside
|
||||
}
|
||||
j = i
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
/** Distance from ([x], [y]) to the shape, `0` when the point is inside it. */
|
||||
fun distanceTo(x: Float, y: Float): Float {
|
||||
if (contains(x, y)) return 0f
|
||||
var best = Float.MAX_VALUE
|
||||
for (ring in rings) {
|
||||
var j = ring.size - 2
|
||||
var i = 0
|
||||
while (i < ring.size) {
|
||||
val d = segmentDistanceSq(x, y, ring[j], ring[j + 1], ring[i], ring[i + 1])
|
||||
if (d < best) best = d
|
||||
j = i
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
return if (best == Float.MAX_VALUE) Float.MAX_VALUE else sqrt(best)
|
||||
}
|
||||
|
||||
private fun segmentDistanceSq(
|
||||
px: Float, py: Float,
|
||||
ax: Float, ay: Float,
|
||||
bx: Float, by: Float,
|
||||
): Float {
|
||||
val dx = bx - ax
|
||||
val dy = by - ay
|
||||
val len = dx * dx + dy * dy
|
||||
val t = if (len <= 0f) 0f else (((px - ax) * dx + (py - ay) * dy) / len).coerceIn(0f, 1f)
|
||||
val cx = px - (ax + t * dx)
|
||||
val cy = py - (ay + t * dy)
|
||||
return cx * cx + cy * cy
|
||||
}
|
||||
}
|
||||
|
||||
/** The whole map, in SVG user units, as a flat set of named shapes. */
|
||||
class MapWorld(val width: Float, val height: Float, val shapes: List<MapShape>) {
|
||||
|
||||
val byCode: Map<String, MapShape> = shapes.associateBy { it.code }
|
||||
|
||||
/** Country outlines, keyed by ISO code. Drawn as borders when regions are on. */
|
||||
val countries: List<MapShape> = shapes.filter { !it.isState }
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package net.helcel.beans.svg
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import net.helcel.beans.countries.World
|
||||
import net.helcel.beans.helper.AUTO_GROUP
|
||||
import net.helcel.beans.helper.Data.groups
|
||||
import net.helcel.beans.helper.Data.visits
|
||||
import net.helcel.beans.helper.NO_GROUP
|
||||
import net.helcel.beans.helper.Settings
|
||||
import net.helcel.beans.helper.Theme.colorToHex6
|
||||
|
||||
|
||||
class CSSWrapper(private val ctx: Context) {
|
||||
|
||||
private val continents: String = World.WWW.children.joinToString(",") { "#${it.code}2" }
|
||||
private val countries: String = World.WWW.children.joinToString(",") { itt ->
|
||||
itt.children.joinToString(",") { "#${it.code}2" }
|
||||
}
|
||||
private val regional: String = World.WWW.children.joinToString(",") { itt ->
|
||||
itt.children.joinToString(",") { "#${it.code}1" }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getBaseColors() : Pair<String, String> {
|
||||
val colorForeground = colorToHex6(MaterialTheme.colors.onBackground.toArgb().toDrawable())
|
||||
val colorBackground = colorToHex6(MaterialTheme.colors.background.toArgb().toDrawable())
|
||||
|
||||
return Pair(colorForeground, colorBackground)
|
||||
}
|
||||
|
||||
private var customCSS: String = ""
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
|
||||
private fun refresh() {
|
||||
val id = if (Settings.isRegional(ctx)) "1" else "2"
|
||||
customCSS = visits.getVisitedByValue().map { (k, v) ->
|
||||
(if (groups.getGroupFromKey(k).key != NO_GROUP) {
|
||||
v
|
||||
} else if (!Settings.isRegional(ctx) && k == AUTO_GROUP) {
|
||||
v.filter { it !in World.WWW.children.map { it1 -> it1.code } }
|
||||
} else {
|
||||
emptyList()
|
||||
}).takeIf { it.isNotEmpty() }
|
||||
?.joinToString(",") { "#${it}$id,#${it}" } + "{fill:${
|
||||
if (k == AUTO_GROUP) colorToHex6(groups.getGroupFromPos(0).second.color)
|
||||
else colorToHex6(groups.getGroupFromKey(k).color)
|
||||
};}"
|
||||
}.joinToString("")
|
||||
}
|
||||
@Composable
|
||||
fun get(): String {
|
||||
val (colorForeground,colorBackground) = getBaseColors()
|
||||
refresh()
|
||||
return if (Settings.isRegional(ctx)) {
|
||||
val countryRegionalCSS: String =
|
||||
"svg{fill:$colorForeground;stroke:$colorBackground;stroke-width:0.01;}" +
|
||||
"$continents,$countries{fill:none;stroke:$colorBackground;stroke-width:0.1;}"
|
||||
countryRegionalCSS + customCSS
|
||||
} else {
|
||||
val countryOnlyCSS: String =
|
||||
"svg{fill:$colorForeground;stroke:$colorBackground;stroke-width:0.1;}" +
|
||||
"${regional}{display:none;}"
|
||||
countryOnlyCSS + customCSS
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package net.helcel.beans.svg
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.caverock.androidsvg.SVG
|
||||
import net.helcel.beans.R
|
||||
|
||||
class SVGWrapper(ctx: Context) {
|
||||
|
||||
private val sharedPreferences: SharedPreferences =
|
||||
PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
private val svgFile = when (sharedPreferences.getString(
|
||||
ctx.getString(R.string.key_projection),
|
||||
ctx.getString(R.string.mercator)
|
||||
)) {
|
||||
ctx.getString(R.string.azimuthalequidistant) -> "aeqd01.svg"
|
||||
ctx.getString(R.string.loximuthal) -> "loxim01.svg"
|
||||
ctx.getString(R.string.mercator) -> "webmercator01.svg"
|
||||
else -> "webmercator01.svg"
|
||||
}
|
||||
|
||||
private var svg: SVG? = SVG.getFromAsset(ctx.assets, svgFile)
|
||||
|
||||
fun get(): SVG? {
|
||||
return svg
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="128"
|
||||
android:viewportHeight="128">
|
||||
<group android:scaleX="0.8918919"
|
||||
android:scaleY="0.8918919"
|
||||
android:translateX="6.918919"
|
||||
android:translateY="6.918919">
|
||||
<group
|
||||
android:scaleX="1.25"
|
||||
android:scaleY="1.25"
|
||||
android:translateX="18"
|
||||
android:translateY="18">
|
||||
<path
|
||||
android:pathData="M56.803,45.75c0.599,-0.299 1.157,-0.672 1.663,-1.11 0.804,-0.698 1.463,-1.546 1.94,-2.497 0.599,-1.303 0.883,-2.728 0.829,-4.161 0.034,-1.498 -0.154,-2.994 -0.558,-4.437 -0.451,-1.645 -1.104,-3.229 -1.944,-4.714 -0.967,-2.141 -2.28,-4.108 -3.887,-5.822 -1.242,-1.326 -2.757,-2.366 -4.44,-3.048 -0.877,-0.372 -1.821,-0.56 -2.774,-0.553 -1.402,0.074 -2.729,0.697 -3.882,1.389 -1.285,0.81 -2.244,1.735 -2.771,3.053 -0.739,2.362 0.827,4.821 1.113,5.269 0.003,0.005 0.187,0.281 0.555,0.832 1.512,2.264 1.589,2.358 1.666,2.495 0.646,1.221 1.114,2.528 1.389,3.882 1.043,4.001 1.565,6.001 2.223,6.932 1.208,1.71 3.455,3.414 5.826,3.325 1.059,-0.088 2.093,-0.371 3.05,-0.835Z"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="@color/white"
|
||||
android:strokeColor="#000000"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M22,41c-1.54,0.554 -2.83,1.642 -3.636,3.066 -0.714,1.365 -0.957,2.928 -0.693,4.445 0.178,1.247 0.632,2.439 1.329,3.488 2.032,3.228 5.383,4.423 7,5 1.613,0.561 3.295,0.897 5,1 2.387,0.205 6.923,0.535 11,-2 1.287,-0.665 2.335,-1.713 3,-3 0.661,-1.601 0.661,-3.399 0,-5 -0.389,-1.156 -1.121,-2.165 -2.099,-2.894 -0.919,-0.599 -1.974,-0.956 -3.069,-1.039 -2.057,-0.313 -2.756,0.139 -5.014,0.057 -1.271,-0.019 -2.534,-0.214 -3.753,-0.577 -3.036,-0.95 -3.33,-2.457 -5.889,-2.829 -1.066,-0.158 -2.155,-0.061 -3.177,0.282Z"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="@color/white"
|
||||
android:strokeColor="#000000"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M12.7,26.975c1.758,-3.604 5.311,-3.191 9.58,-7.091 2.701,-2.469 2.858,-4.078 5.613,-5.202 1.407,-0.644 2.984,-0.819 4.498,-0.5 2.053,0.475 3.761,1.891 4.61,3.819 1.116,2.742 -0.314,5.437 -1.398,7.482 -0.951,1.689 -2.167,3.215 -3.602,4.518 -1.479,1.552 -3.161,2.898 -5,4 -2.629,1.541 -6.332,3.711 -9.989,2.43 -1.481,-0.548 -2.768,-1.521 -3.699,-2.796 -1.496,-1.906 -1.735,-4.512 -0.612,-6.659Z"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="@color/white"
|
||||
android:strokeColor="#000000"
|
||||
android:strokeLineCap="round"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -2,4 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Beans" parent="android:Theme.Material.NoActionBar">
|
||||
<!-- Status bar color in dark mode -->
|
||||
<item name="android:statusBarColor">@color/blue</item>
|
||||
<!-- Window background in dark mode -->
|
||||
<item name="android:windowBackground">@color/darkgray</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Beans" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@color/blue</item>
|
||||
<item name="android:windowBackground">@color/blue</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -13,20 +13,37 @@
|
||||
<string name="key_stats">Statistics</string>
|
||||
<string name="counters">Prefer counters (#)</string>
|
||||
<string name="percentages">Prefer percentages (%)</string>
|
||||
<string name="licenses">Licenses</string>
|
||||
<string name="key_group">Groups</string>
|
||||
<string name="key_regional">Regional</string>
|
||||
<string name="key_regional">Regional mode</string>
|
||||
<string name="key_regional_desc">Enable tracking of states and provinces</string>
|
||||
<string name="key_cascade_stats">key_cascade_stats</string>
|
||||
<string name="pref_cascade_stats">Cascade statistics</string>
|
||||
<string name="pref_cascade_stats_desc">Include parents and children in the counts</string>
|
||||
<string name="key_group_desc">Enable multiple colors</string>
|
||||
<string name="key_touch_radius">key_touch_radius</string>
|
||||
<string name="pref_touch_radius">Tap radius</string>
|
||||
<string name="pref_touch_radius_desc">How far around a tap on the map other places should be considered</string>
|
||||
<string name="pref_touch_radius_value">%1$d dp</string>
|
||||
<string name="about">About</string>
|
||||
<string name="pref_category_appearance">Appearance</string>
|
||||
<string name="pref_category_features">Features</string>
|
||||
<string name="pref_category_data">Data</string>
|
||||
<string name="action_import">Import</string>
|
||||
<string name="action_export">Export</string>
|
||||
<string name="import_desc">Load data from a previously exported file.</string>
|
||||
<string name="export_desc">Save your current data to a file for backup or transfer.</string>
|
||||
<string name="key_projection_desc">Select how the map is projected onto a 2D surface.</string>
|
||||
<string name="beans_is_foss">Beans is free and open source software, licensed under the GNU General Public License (version 3 or later)</string>
|
||||
<string name="beans_repo_uri">https://github.com/helcel-net/beans</string>
|
||||
<string name="beans_repo">Project repository: %1$s\n Feel free to report issues or contribute.</string>
|
||||
<string name="foss_licenses">Free and open source dependencies and licenses</string>
|
||||
<string name="about_beans">About the Beans application</string>
|
||||
<string name="edit_group">Select the group to assign.</string>
|
||||
<string name="edit_group_sub">Long press on a group to edit its name and color.</string>
|
||||
<string name="select_group">Select the group to keep.</string>
|
||||
<string name="select_group_sub">All others will be deleted and its mappings reassigned to the group you choose here.</string>
|
||||
<string name="delete_group">Are your sure you want to delete this group and remove all its country mappings?</string>
|
||||
<string name="select_place">Select a place</string>
|
||||
<string name="select_place_sub">Tap the place to color, or a larger one that contains it.</string>
|
||||
<string name="delete_regions">Are you sure you want to disable regions and reassign all regional mappings to the corresponding countries?</string>
|
||||
|
||||
<string name="name">Name</string>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Beans" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<!-- Status bar color -->
|
||||
<item name="android:statusBarColor">@color/blue</item>
|
||||
<!-- Window background - shows through statusBarsPadding() area -->
|
||||
<item name="android:windowBackground">@color/blue</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
|
||||
</style>
|
||||
</resources>
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
id 'com.android.application' version '8.13.0' apply false
|
||||
id 'com.android.library' version '8.13.0' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '2.2.20' apply false
|
||||
id 'com.android.application' version '9.4.0' apply false
|
||||
id 'com.android.library' version '9.3.2' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '2.4.10' apply false
|
||||
}
|
||||
+2
-8
@@ -1,6 +1,6 @@
|
||||
#!/bin/node
|
||||
|
||||
import {readFileSync, existsSync} from 'fs'
|
||||
import {readFileSync,existsSync} from 'fs'
|
||||
import area from '@turf/area'
|
||||
import * as turf from '@turf/turf'
|
||||
|
||||
@@ -24,7 +24,7 @@ const countries =
|
||||
|
||||
|
||||
const groups = {
|
||||
"EEE":["ALB","AND","AUT","BLR","BEL","BIH","BGR","HRV","CYP","CZE","DNK","EST","FIN","FRA","DEU","GRC","HUN","ISL","IRL","ITA","KAZ","XKO","LVA","LIE","LTU","LUX","MLT","MDA","MCO","MNE","NLD","MKD","NOR","POL","PRT","ROU","RUS","SMR","SRB","SVK","SVN","ESP","SWE","CHE","UKR","GBR","VAT","XAD"],
|
||||
"EEE":["ALA","ALB","AND","AUT","BLR","BEL","BIH","BGR","HRV","CYP","CZE","DNK","EST","FIN","FRA","DEU","GIB","GGY","GRC","HUN","ISL","IRL","IMN","JEY","ITA","KAZ","XKO","LVA","LIE","LTU","LUX","MLT","MDA","MCO","MNE","NLD","MKD","NOR","SJM","POL","PRT","ROU","RUS","SMR","SRB","SVK","SVN","ESP","SWE","CHE","UKR","GBR","VAT","XAD"],
|
||||
"ABB":["AFG","ARM","AZE","BHR","BGD","BTN","BRN","KHM","CHN","GEO","HKG","IND","IDN","IRN","IRQ","ISR","JPN","JOR","KWT","KGZ","LAO","LBN","MAC","MYS","MDV","MNG","MMR","NPL","PRK","OMN","PAK","PSE","PHL","QAT","SAU","SGP","KOR","LKA","SYR","TWN","TJK","THA","TLS","TUR","TKM","ARE","UZB","VNM","YEM","ZNC"],
|
||||
"FFF":["DZA","AGO","BDI","BEN","BWA","BFA","BDI","CPV","CMR","CAF","TCD","COM","COG","COD","CIV","DJI","EGY","GNQ","ERI","SWZ","ETH","GAB","GMB","GHA","GIN","GNB","KEN","LSO","LBR","LBY","MDG","MWI","MLI","MRT","MUS","MYT","MAR","MOZ","NAM","NER","NGA","COD","REU","RWA","STP","SEN","SYC","SLE","SOM","ZAF","SSD","SHN","SDN","TZA","TGO","TUN","UGA","COD","ZMB","ZWE","ESH"],
|
||||
"NNN":["ABW","AIA","ATG","BHS","BRB","BLZ","BMU","VGB","CAN","CYM","CRI","CUB","CUW","DMA","DOM","SLV","GRL","GRD","GLP","GTM","HTI","HND","JAM","MTQ","MEX","MSR","ANT","CUW","NIC","PAN","PRI","KNA","LCA","MAF","SPM","VCT","TTO","TCA","USA","XCL"],
|
||||
@@ -32,7 +32,6 @@ const groups = {
|
||||
"UUU":["ASM","AUS","COK","FJI","PYF","GUM","KIR","MHL","FSM","NRU","NCL","NZL","NIU","NFK","MNP","PLW","PNG","PCN","SLB","TKL","TON","TUV","VUT","WLF"],
|
||||
"XXX":[
|
||||
"ATA", // Antarctica: not in any other region
|
||||
"ALA",// Åland Islands: an autonomous region of Finland, but not a member of the EU or UN
|
||||
"BES",// Bonaire, Sint Eustatius and Saba: special municipalities of the Netherlands in the Caribbean
|
||||
"BVT",// Bouvet Island: an uninhabited territory of Norway in the South Atlantic
|
||||
"IOT",// British Indian Ocean Territory: a British overseas territory in the Indian Ocean
|
||||
@@ -40,16 +39,11 @@ const groups = {
|
||||
"CCK",// Cocos (Keeling) Islands: an Australian external territory in the Indian Ocean
|
||||
"FRO",// Faroe Islands: an autonomous region of Denmark
|
||||
"ATF",// French Southern and Antarctic Lands: a territory of France located in the southern Indian Ocean
|
||||
"GIB",// Gibraltar: a British overseas territory located at the southern tip of the Iberian Peninsula
|
||||
"GGY",// Guernsey: a British Crown dependency in the English Channel
|
||||
"HMD",// Heard Island and McDonald Islands: an uninhabited Australian external territory in the southern Indian Ocean
|
||||
"IMN",// Isle of Man: a British Crown dependency located in the Irish Sea
|
||||
"JEY",// Jersey: a British Crown dependency located in the English Channel
|
||||
"BLM",// Saint Barthélemy: an overseas collectivity of France in the Caribbean
|
||||
"WSM",// Samoa: an independent island nation in the South Pacific
|
||||
"SXM",// Sint Maarten: a constituent country of the Kingdom of the Netherlands in the Caribbean
|
||||
"SGS",// South Georgia and the South Sandwich Islands: a British overseas territory in the southern Atlantic Ocean
|
||||
"SJM",// Svalbard and Jan Mayen: an archipelago administered by Norway
|
||||
"UMI",// United States Minor Outlying Islands: a collection of nine insular areas of the United States
|
||||
"VIR",// United States Virgin Islands: an unincorporated territory of the United States in the Caribbean
|
||||
]
|
||||
|
||||
+48
-43
@@ -1,17 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
LOCAL_SVG_PATH="app/src/main/assets/"
|
||||
|
||||
GADM_VERSION="4.1"
|
||||
GADM_BASEPATH="https://geodata.ucdavis.edu/gadm"
|
||||
|
||||
mapshaper="./node_modules/mapshaper/bin/mapshaper"
|
||||
ATA_URL="https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/905b0baf5f4fb3b9ccf45293647dcacdb2b799d4/releaseData/gbOpen/ATA/ADM0/geoBoundaries-ATA-ADM0_simplified.geojson"
|
||||
|
||||
# Caspian Sea: "XCA"
|
||||
countries=(
|
||||
"AFG" "XAD" "ALA" "ALB" "DZA" "ASM" "AND" "AGO" "AIA" "ATG" "ARG" "ARM" "ABW" "AUS" "AUT" "AZE"
|
||||
"BHS" "BHR" "BGD" "BRB" "BLR" "BEL" "BLZ" "BEN" "BMU" "BTN" "BOL" "BES" "BIH" "BWA" "BVT" "BRA" "IOT" "VGB" "BRN" "BGR" "BFA" "BDI" "KHM"
|
||||
"CMR" "CAN" "CPV" "XCA" "CYM" "CAF" "TCD" "CHL" "CHN" "CXR" "XCL" "CCK" "COL" "COM" "COK" "CRI" "CIV" "HRV" "CUB" "CUW" "CYP" "CZE" "COD"
|
||||
"CMR" "CAN" "CPV" "CYM" "CAF" "TCD" "CHL" "CHN" "CXR" "XCL" "CCK" "COL" "COM" "COK" "CRI" "CIV" "HRV" "CUB" "CUW" "CYP" "CZE" "COD"
|
||||
"DNK" "DJI" "DMA" "DOM" "ECU" "EGY" "SLV" "GNQ" "ERI" "EST" "ETH" "FLK" "FRO" "FJI" "FIN" "FRA" "GUF" "PYF" "ATF"
|
||||
"GAB" "GMB" "GEO" "DEU" "GHA" "GIB" "GRC" "GRL" "GRD" "GLP" "GUM" "GTM" "GGY" "GIN" "GNB" "GUY" "HTI" "HMD" "HND" "HUN"
|
||||
"ISL" "IND" "IDN" "IRN" "IRQ" "IRL" "IMN" "ISR" "ITA" "JAM" "JPN" "JEY" "JOR" "KAZ" "KEN" "KIR" "XKO" "KWT" "KGZ"
|
||||
@@ -79,44 +78,47 @@ download_1() {
|
||||
}
|
||||
|
||||
|
||||
toSVG_0() {
|
||||
local input_files=("ATA")
|
||||
generate_map() {
|
||||
local OUT_FILE="$1" # First argument: Output destination path
|
||||
local PROJ_ARGS="$2" # Second argument: Projection parameters
|
||||
shift 2 # Remove the first two arguments, leaving only the files
|
||||
local FILES_TO_RUN=("$@") # Capture all remaining arguments as the file array
|
||||
|
||||
for country in "${countries[@]}"
|
||||
do
|
||||
input_file="./temp/0/${country}.json"
|
||||
if [ -f "$input_file" ]; then
|
||||
input_files+=("$input_file")
|
||||
else
|
||||
echo "Input file $input_file not found."
|
||||
fi
|
||||
done
|
||||
echo "Generating: $OUT_FILE using projection [$PROJ_ARGS]"
|
||||
echo "Processing ${#FILES_TO_RUN[@]} files..."
|
||||
|
||||
# Each feature is tagged with the place it belongs to and whether it is a
|
||||
# country outline or one of its regions. packmap.js groups on that; nothing
|
||||
# downstream needs the features split into layers.
|
||||
local JS_PIPELINE_LOGIC="
|
||||
const conflicts = {
|
||||
'Z01': 'IND', 'Z04': 'IND', 'Z05': 'IND', 'Z07': 'IND', 'Z09': 'IND',
|
||||
'Z02': 'CHN', 'Z03': 'CHN','Z08': 'CHN',
|
||||
'Z06': 'PAK'
|
||||
};
|
||||
let rawCode = GID_0 || 'UNK';
|
||||
let cCode = conflicts[rawCode] ? conflicts[rawCode] : rawCode;
|
||||
// Antarctica's boundary file carries no country code at either level.
|
||||
if (cCode === 'UNK') cCode = 'ATA';
|
||||
let isGidMissing = (!GID || GID === 'undefined' || GID === 'null' || GID === '');
|
||||
CODE = isGidMissing ? cCode : GID;
|
||||
LEVEL = isGidMissing ? 0 : 1;
|
||||
"
|
||||
|
||||
"$mapshaper" -i "${FILES_TO_RUN[@]}" combine-files \
|
||||
-snap \
|
||||
-merge-layers force\
|
||||
-proj $PROJ_ARGS densify \
|
||||
-simplify 10% weighted keep-shapes \
|
||||
-filter-islands min-area=0 \
|
||||
-sort 'this.area' ascending \
|
||||
-each "$JS_PIPELINE_LOGIC" \
|
||||
-o "$OUT_FILE" \
|
||||
format=geojson
|
||||
|
||||
"$mapshaper" -i combine-files ${input_files[@]} -proj webmercator -simplify 0.005 weighted keep-shapes resolution=1200x1200 -o ./app/src/main/assets/mercator0.svg svg-data=GID_0,COUNTRY id-field=GID_0
|
||||
"$mapshaper" -i combine-files ${input_files[@]} -proj aeqd +lat_0=90 -simplify 0.005 weighted keep-shapes resolution=1200x1200 -o ./app/src/main/assets/aeqd0.svg svg-data=GID_0,COUNTRY id-field=GID_0
|
||||
}
|
||||
|
||||
toSVG_1() {
|
||||
input_files=("ATA")
|
||||
|
||||
for country in "${countries[@]}"
|
||||
do
|
||||
input_file="./temp/1/${country}.json"
|
||||
# input_file="./temp/1/gadm41_${country}_1.json"
|
||||
if [ -f "$input_file" ]; then
|
||||
input_files+=("$input_file")
|
||||
else
|
||||
echo "Input file $input_file not found."
|
||||
fi
|
||||
done
|
||||
|
||||
"$mapshaper" -i combine-files ${input_files[@]} -proj webmercator -simplify 0.005 weighted keep-shapes resolution=1200x1200 -o ./app/src/main/assets/mercator1.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
|
||||
"$mapshaper" -i combine-files ${input_files[@]} -proj aeqd +lat_0=90 -simplify 0.005 weighted keep-shapes resolution=1200x1200 -o ./app/src/main/assets/aeqd1.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
|
||||
}
|
||||
|
||||
|
||||
toSVG_01() {
|
||||
build_maps() {
|
||||
input_files=()
|
||||
|
||||
input_files+=("./temp/1/ATA.json")
|
||||
@@ -136,9 +138,12 @@ toSVG_01() {
|
||||
fi
|
||||
done
|
||||
|
||||
"$mapshaper" -i combine-files ${input_files[@]} snap -proj loxim densify -simplify 0.001 weighted keep-shapes -o ./app/src/main/assets/loxim01.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
|
||||
"$mapshaper" -i combine-files ${input_files[@]} snap -proj webmercator densify -simplify 0.001 weighted keep-shapes -o ./app/src/main/assets/webmercator01.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
|
||||
"$mapshaper" -i combine-files ${input_files[@]} snap -proj aeqd +lat_0=90 densify -simplify 0.001 weighted keep-shapes -o ./app/src/main/assets/aeqd01.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
|
||||
generate_map "./mapdata/loxim01.geojson" "loxim" "${input_files[@]}"
|
||||
generate_map "./mapdata/webmercator01.geojson" "webmercator" "${input_files[@]}"
|
||||
generate_map "./mapdata/aeqd01.geojson" "aeqd +lat_0=90" "${input_files[@]}"
|
||||
|
||||
# The app reads the packed form; the GeoJSON above is only an intermediate.
|
||||
node packmap.js ./mapdata/loxim01.geojson ./mapdata/webmercator01.geojson ./mapdata/aeqd01.geojson
|
||||
}
|
||||
|
||||
do_1() {
|
||||
@@ -153,10 +158,10 @@ do_0() {
|
||||
do
|
||||
download_0 "$country"
|
||||
done
|
||||
wget -q -O "./temp/1/ATA.json" "$ATA_URL"
|
||||
wget -q -O "./temp/0/ATA.json" "$ATA_URL"
|
||||
}
|
||||
# do_0
|
||||
# do_1
|
||||
# toSVG_0
|
||||
# toSVG_1
|
||||
toSVG_01
|
||||
build_maps
|
||||
|
||||
#CUW, CCK, XCL, CXR, IOT, BVT, ABW, FLK, GIB, HMD, KIR, SXM, MDV, MCO, NIU, NFK, PCN, MAF, SGS, VAT
|
||||
+4
-19
@@ -1,24 +1,9 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=false
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.uniquePackageNames=false
|
||||
android.dependency.useConstraints=false
|
||||
android.r8.strictFullModeForKeepRules=false
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+3
-2
@@ -1,8 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
@@ -29,7 +29,7 @@
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
@@ -57,7 +57,7 @@
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
|
||||
Vendored
+12
-23
@@ -19,12 +19,12 @@
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@@ -51,7 +51,7 @@ echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
@@ -65,29 +65,18 @@ echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
|
||||
-325
@@ -1,325 +0,0 @@
|
||||
var continents = {
|
||||
XAD:"EEE",
|
||||
ALA:"EEE",
|
||||
ALB:"EEE",
|
||||
AND:"EEE",
|
||||
AUT:"EEE",
|
||||
BLR:"EEE",
|
||||
BEL:"EEE",
|
||||
BIH:"EEE",
|
||||
BGR:"EEE",
|
||||
HRV:"EEE",
|
||||
CYP:"EEE",
|
||||
CZE:"EEE",
|
||||
DNK:"EEE",
|
||||
EST:"EEE",
|
||||
FRO:"EEE",
|
||||
FIN:"EEE",
|
||||
FRA:"EEE",
|
||||
DEU:"EEE",
|
||||
GIB:"EEE",
|
||||
GRC:"EEE",
|
||||
GGY:"EEE",
|
||||
HUN:"EEE",
|
||||
ISL:"EEE",
|
||||
IRL:"EEE",
|
||||
IMN:"EEE",
|
||||
ITA:"EEE",
|
||||
JEY:"EEE",
|
||||
KAZ:"EEE",
|
||||
XKO:"EEE",
|
||||
LVA:"EEE",
|
||||
LIE:"EEE",
|
||||
LTU:"EEE",
|
||||
LUX:"EEE",
|
||||
MLT:"EEE",
|
||||
MDA:"EEE",
|
||||
MCO:"EEE",
|
||||
MNE:"EEE",
|
||||
NLD:"EEE",
|
||||
MKD:"EEE",
|
||||
NOR:"EEE",
|
||||
POL:"EEE",
|
||||
PRT:"EEE",
|
||||
ROU:"EEE",
|
||||
RUS:"EEE",
|
||||
SMR:"EEE",
|
||||
SRB:"EEE",
|
||||
SVK:"EEE",
|
||||
SVN:"EEE",
|
||||
ESP:"EEE",
|
||||
SJM:"EEE",
|
||||
SWE:"EEE",
|
||||
CHE:"EEE",
|
||||
UKR:"EEE",
|
||||
GBR:"EEE",
|
||||
VAT:"EEE",
|
||||
|
||||
AFG:"ABB",
|
||||
ARM:"ABB",
|
||||
AZE:"ABB",
|
||||
BHR:"ABB",
|
||||
BGD:"ABB",
|
||||
BTN:"ABB",
|
||||
IOT:"ABB",
|
||||
BRN:"ABB",
|
||||
KHM:"ABB",
|
||||
CCK:"ABB",
|
||||
CHN:"ABB",
|
||||
CXR:"ABB",
|
||||
GEO:"ABB",
|
||||
HKG:"ABB",
|
||||
IND:"ABB",
|
||||
IDN:"ABB",
|
||||
IRN:"ABB",
|
||||
IRQ:"ABB",
|
||||
ISR:"ABB",
|
||||
JPN:"ABB",
|
||||
JOR:"ABB",
|
||||
KWT:"ABB",
|
||||
KGZ:"ABB",
|
||||
LAO:"ABB",
|
||||
LBN:"ABB",
|
||||
MAC:"ABB",
|
||||
MYS:"ABB",
|
||||
MDV:"ABB",
|
||||
MNG:"ABB",
|
||||
MMR:"ABB",
|
||||
NPL:"ABB",
|
||||
PRK:"ABB",
|
||||
OMN:"ABB",
|
||||
PAK:"ABB",
|
||||
PSE:"ABB",
|
||||
PHL:"ABB",
|
||||
QAT:"ABB",
|
||||
SAU:"ABB",
|
||||
SGP:"ABB",
|
||||
KOR:"ABB",
|
||||
LKA:"ABB",
|
||||
SYR:"ABB",
|
||||
TWN:"ABB",
|
||||
TJK:"ABB",
|
||||
THA:"ABB",
|
||||
TLS:"ABB",
|
||||
TUR:"ABB",
|
||||
TKM:"ABB",
|
||||
ARE:"ABB",
|
||||
UZB:"ABB",
|
||||
VNM:"ABB",
|
||||
YEM:"ABB",
|
||||
ZNC:"ABB",
|
||||
|
||||
DZA:"FFF",
|
||||
AGO:"FFF",
|
||||
BDI:"FFF",
|
||||
BEN:"FFF",
|
||||
BWA:"FFF",
|
||||
BFA:"FFF",
|
||||
BDI:"FFF",
|
||||
CPV:"FFF",
|
||||
CMR:"FFF",
|
||||
CAF:"FFF",
|
||||
TCD:"FFF",
|
||||
COM:"FFF",
|
||||
COG:"FFF",
|
||||
COD:"FFF",
|
||||
CIV:"FFF",
|
||||
DJI:"FFF",
|
||||
EGY:"FFF",
|
||||
GNQ:"FFF",
|
||||
ERI:"FFF",
|
||||
ETH:"FFF",
|
||||
ATF:"FFF",
|
||||
GAB:"FFF",
|
||||
GMB:"FFF",
|
||||
GHA:"FFF",
|
||||
GIN:"FFF",
|
||||
GNB:"FFF",
|
||||
KEN:"FFF",
|
||||
LSO:"FFF",
|
||||
LBR:"FFF",
|
||||
LBY:"FFF",
|
||||
MDG:"FFF",
|
||||
MWI:"FFF",
|
||||
MLI:"FFF",
|
||||
MRT:"FFF",
|
||||
MUS:"FFF",
|
||||
MYT:"FFF",
|
||||
MAR:"FFF",
|
||||
MOZ:"FFF",
|
||||
NAM:"FFF",
|
||||
NER:"FFF",
|
||||
NGA:"FFF",
|
||||
COD:"FFF",
|
||||
REU:"FFF",
|
||||
RWA:"FFF",
|
||||
STP:"FFF",
|
||||
SEN:"FFF",
|
||||
SYC:"FFF",
|
||||
SLE:"FFF",
|
||||
SOM:"FFF",
|
||||
ZAF:"FFF",
|
||||
SSD:"FFF",
|
||||
SHN:"FFF",
|
||||
SDN:"FFF",
|
||||
SWZ:"FFF",
|
||||
TZA:"FFF",
|
||||
TGO:"FFF",
|
||||
TUN:"FFF",
|
||||
UGA:"FFF",
|
||||
COD:"FFF",
|
||||
ZMB:"FFF",
|
||||
ZWE:"FFF",
|
||||
ESH:"FFF",
|
||||
|
||||
ABW:"NNN",
|
||||
AIA:"NNN",
|
||||
ATG:"NNN",
|
||||
BHS:"NNN",
|
||||
BRB:"NNN",
|
||||
BLZ:"NNN",
|
||||
BMU:"NNN",
|
||||
BES:"NNN",
|
||||
VGB:"NNN",
|
||||
CAN:"NNN",
|
||||
CYM:"NNN",
|
||||
XCL:"NNN",
|
||||
CRI:"NNN",
|
||||
CUB:"NNN",
|
||||
CUW:"NNN",
|
||||
DMA:"NNN",
|
||||
DOM:"NNN",
|
||||
SLV:"NNN",
|
||||
GRL:"NNN",
|
||||
GRD:"NNN",
|
||||
GLP:"NNN",
|
||||
GTM:"NNN",
|
||||
HTI:"NNN",
|
||||
HND:"NNN",
|
||||
JAM:"NNN",
|
||||
MTQ:"NNN",
|
||||
MEX:"NNN",
|
||||
MSR:"NNN",
|
||||
ANT:"NNN",
|
||||
CUW:"NNN",
|
||||
NIC:"NNN",
|
||||
PAN:"NNN",
|
||||
PRI:"NNN",
|
||||
BLM:"NNN",
|
||||
KNA:"NNN",
|
||||
LCA:"NNN",
|
||||
MAF:"NNN",
|
||||
SPM:"NNN",
|
||||
VCT:"NNN",
|
||||
SXM:"NNN",
|
||||
TTO:"NNN",
|
||||
TCA:"NNN",
|
||||
USA:"NNN",
|
||||
UMI:"NNN",
|
||||
VIR:"NNN",
|
||||
|
||||
ARG:"SRR",
|
||||
BOL:"SRR",
|
||||
BRA:"SRR",
|
||||
CHL:"SRR",
|
||||
COL:"SRR",
|
||||
ECU:"SRR",
|
||||
FLK:"SRR",
|
||||
GUF:"SRR",
|
||||
GUY:"SRR",
|
||||
PRY:"SRR",
|
||||
PER:"SRR",
|
||||
SUR:"SRR",
|
||||
URY:"SRR",
|
||||
VEN:"SRR",
|
||||
|
||||
ASM:"UUU",
|
||||
AUS:"UUU",
|
||||
COK:"UUU",
|
||||
FJI:"UUU",
|
||||
PYF:"UUU",
|
||||
GUM:"UUU",
|
||||
KIR:"UUU",
|
||||
MHL:"UUU",
|
||||
FSM:"UUU",
|
||||
NRU:"UUU",
|
||||
NCL:"UUU",
|
||||
NZL:"UUU",
|
||||
NIU:"UUU",
|
||||
NFK:"UUU",
|
||||
MNP:"UUU",
|
||||
PLW:"UUU",
|
||||
PNG:"UUU",
|
||||
PCN:"UUU",
|
||||
WSM:"UUU",
|
||||
SLB:"UUU",
|
||||
TKL:"UUU",
|
||||
TON:"UUU",
|
||||
TUV:"UUU",
|
||||
VUT:"UUU",
|
||||
WLF:"UUU",
|
||||
|
||||
ATA:"XXX",
|
||||
BVT:"XXX",
|
||||
HMD:"XXX",
|
||||
SGS:"XXX",
|
||||
}
|
||||
|
||||
import fs from "fs"
|
||||
import {JSDOM} from "jsdom"
|
||||
|
||||
function groupByContinent(svgFilePath) {
|
||||
// Read the SVG file
|
||||
fs.readFile(svgFilePath, 'utf-8', (err, data) => {
|
||||
if (err) {
|
||||
console.error('Error reading SVG file:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a virtual DOM with JSDOM
|
||||
const dom = new JSDOM(data);
|
||||
const document = dom.window.document;
|
||||
|
||||
// Select the root <svg> element
|
||||
const svgElement = document.querySelector('svg');
|
||||
|
||||
// Group <g> elements representing countries by continent
|
||||
const world = {}; // Renamed continents object to 'world'
|
||||
svgElement.querySelectorAll('g').forEach(countryGroup => {
|
||||
const countryId = countryGroup.getAttribute('id');
|
||||
if (countryId!= null){
|
||||
const continentId = continents[countryId.replace(/\d/g, "")] || "XXX"; // Remove numbers from country ID to get continent ID
|
||||
if (!world[continentId]) {
|
||||
world[continentId] = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
world[continentId].setAttribute('id', continentId);
|
||||
}
|
||||
world[continentId].innerHTML += countryGroup.outerHTML;
|
||||
}else{
|
||||
|
||||
console.log(countryId,countryGroup.outerHTML)
|
||||
}
|
||||
});
|
||||
|
||||
// Create new <g> elements for each continent
|
||||
svgElement.innerHTML = '';
|
||||
for (const continentId in world) {
|
||||
svgElement.appendChild(world[continentId]);
|
||||
}
|
||||
|
||||
// Output the modified SVG
|
||||
fs.writeFile(svgFilePath, svgElement.outerHTML, 'utf-8', err => {
|
||||
if (err) {
|
||||
console.error('Error writing to SVG file:', err);
|
||||
return;
|
||||
}
|
||||
console.log('SVG file updated successfully.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Example usage:
|
||||
const svgFileBasePath = "./app/src/main/assets/" ;
|
||||
groupByContinent(svgFileBasePath+ "webmercator01.svg");
|
||||
groupByContinent(svgFileBasePath+ "aeqd01.svg");
|
||||
groupByContinent(svgFileBasePath+ "loxim01.svg");
|
||||
+6
-4
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"scripts": {
|
||||
"pack": "node packmap.js mapdata/loxim01.geojson mapdata/webmercator01.geojson mapdata/aeqd01.geojson"
|
||||
},
|
||||
"dependencies": {
|
||||
"@turf/area": "^7.0.0",
|
||||
"@turf/turf": "^7.0.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"mapshaper": "^0.6.79"
|
||||
"@turf/area": "^7.3.5",
|
||||
"@turf/turf": "^7.3.5",
|
||||
"mapshaper": "^0.7.22"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
#!/bin/node
|
||||
|
||||
/**
|
||||
* Packs the generated map data into the compact binary the app reads.
|
||||
*
|
||||
* The app used to ship SVG, which spends about four megabytes per projection
|
||||
* spelling coordinates out in decimal ASCII inside XML, and needed the map
|
||||
* parsed as a document at startup. Nothing needs a document: the app wants a
|
||||
* list of places and the outlines that belong to them, which is what this
|
||||
* writes.
|
||||
*
|
||||
* Input is mapshaper's projected GeoJSON, where each feature carries:
|
||||
* CODE the place it belongs to, e.g. "FRA" or "FRA_CE"
|
||||
* LEVEL 0 for a country outline, 1 for one of its regions
|
||||
*
|
||||
* Output format. All integers are LEB128 varints and coordinates are in grid
|
||||
* steps, on a canvas MAP_WIDTH user units across:
|
||||
*
|
||||
* "BMAP" 0x02
|
||||
* precision uvarint, grid steps per user unit
|
||||
* width, height uvarint, in grid steps
|
||||
* shapeCount uvarint
|
||||
* per shape:
|
||||
* codeLength, code uvarint + UTF-8 bytes
|
||||
* flags 1 byte, bit 0 = is a region
|
||||
* ringCount uvarint
|
||||
* per ring:
|
||||
* pointCount uvarint
|
||||
* flags 1 byte, bit 0 = even-odd fill
|
||||
* pointCount pairs of dx, dy zigzag varint, from the previous point,
|
||||
* starting from the origin on each ring
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
|
||||
/** Canvas width in user units. Stroke widths in the app are relative to it. */
|
||||
const MAP_WIDTH = 800
|
||||
|
||||
/** Blank edge left around the map, as mapshaper's own SVG output used to. */
|
||||
const MARGIN = 1
|
||||
|
||||
/**
|
||||
* Grid steps per user unit. Coordinates are snapped to this, so it sets how
|
||||
* fine the map can be; it is written into the header rather than agreed by
|
||||
* convention, so raising it only means regenerating the assets.
|
||||
*/
|
||||
const PRECISION = 20
|
||||
|
||||
function uvarint(bytes, value) {
|
||||
while (value >= 0x80) {
|
||||
bytes.push((value & 0x7f) | 0x80)
|
||||
value >>>= 7
|
||||
}
|
||||
bytes.push(value)
|
||||
}
|
||||
|
||||
function svarint(bytes, value) {
|
||||
uvarint(bytes, value < 0 ? -value * 2 - 1 : value * 2)
|
||||
}
|
||||
|
||||
/** Every polygon of a feature, outer rings and holes alike. */
|
||||
function ringsOf(geometry) {
|
||||
if (!geometry) return []
|
||||
if (geometry.type === 'Polygon') return [geometry.coordinates]
|
||||
if (geometry.type === 'MultiPolygon') return geometry.coordinates
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups features by the place they belong to. A country outline and its
|
||||
* regions arrive as separate features; the code is what ties them together.
|
||||
*/
|
||||
function collect(features) {
|
||||
const shapes = []
|
||||
const byCode = new Map()
|
||||
|
||||
for (const feature of features) {
|
||||
const code = feature.properties?.CODE
|
||||
if (!code) continue
|
||||
const polygons = ringsOf(feature.geometry)
|
||||
if (polygons.length === 0) continue
|
||||
|
||||
let shape = byCode.get(code)
|
||||
if (!shape) {
|
||||
shape = { code, isState: feature.properties.LEVEL === 1, rings: [], seen: new Set() }
|
||||
byCode.set(code, shape)
|
||||
shapes.push(shape)
|
||||
}
|
||||
// A polygon with holes only reads correctly under an even-odd fill, which
|
||||
// is the rule mapshaper's SVG output used to pick for the same reason.
|
||||
const evenOdd = polygons.some((polygon) => polygon.length > 1)
|
||||
|
||||
for (const polygon of polygons) {
|
||||
for (const ring of polygon) {
|
||||
if (ring.length >= 3) shape.rings.push({ ring, evenOdd })
|
||||
}
|
||||
}
|
||||
}
|
||||
return shapes
|
||||
}
|
||||
|
||||
/** Twice the signed area of a closed ring of `x, y` pairs. */
|
||||
function shoelace(points) {
|
||||
let sum = 0
|
||||
const count = points.length / 2
|
||||
for (let i = 0, j = count - 1; i < count; j = i++) {
|
||||
sum += points[j * 2] * points[i * 2 + 1] - points[i * 2] * points[j * 2 + 1]
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
/**
|
||||
* Snaps every ring to the output grid and throws away what that makes
|
||||
* redundant: points that land on the one before them, and rings left too small
|
||||
* to enclose anything. The simplification upstream works to about this
|
||||
* precision, so a good share of the raw points say nothing once rounded.
|
||||
*/
|
||||
function quantize(shapes, canvas) {
|
||||
const kept = []
|
||||
for (const shape of shapes) {
|
||||
const seen = new Set()
|
||||
const rings = []
|
||||
for (const { ring, evenOdd } of shape.rings) {
|
||||
const points = []
|
||||
let px = null
|
||||
let py = null
|
||||
for (const [rx, ry] of ring) {
|
||||
const x = canvas.x(rx)
|
||||
const y = canvas.y(ry)
|
||||
if (x === px && y === py) continue
|
||||
points.push(x, y)
|
||||
px = x
|
||||
py = y
|
||||
}
|
||||
if (points.length < 6) continue
|
||||
// Coordinates are integers now, so any ring that still encloses area
|
||||
// encloses at least half a grid square. One that comes out at exactly
|
||||
// zero is a sliver the rounding flattened, and it would draw nothing.
|
||||
if (shoelace(points) === 0) continue
|
||||
// Antarctica arrives twice over, once per admin level, because its source
|
||||
// carries no country code and both levels land on ATA. A ring counted
|
||||
// twice cancels itself out in the even-odd test the app hit tests with,
|
||||
// which would leave the shape impossible to tap.
|
||||
const key = points.join(',')
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
rings.push({ points, evenOdd })
|
||||
}
|
||||
if (rings.length > 0) kept.push({ code: shape.code, isState: shape.isState, rings })
|
||||
}
|
||||
// By code, which puts a country next to its own regions. They are neighbours
|
||||
// on the map, so their coordinates are similar, and the compressor in the APK
|
||||
// gets noticeably more out of the file for it.
|
||||
return kept.sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : 0))
|
||||
}
|
||||
|
||||
/** Projected metres in, user units on a MAP_WIDTH canvas out, y flipped. */
|
||||
function fitToCanvas(shapes) {
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const shape of shapes) {
|
||||
for (const { ring } of shape.rings) {
|
||||
for (const [x, y] of ring) {
|
||||
if (x < minX) minX = x
|
||||
if (x > maxX) maxX = x
|
||||
if (y < minY) minY = y
|
||||
if (y > maxY) maxY = y
|
||||
}
|
||||
}
|
||||
}
|
||||
const scale = (MAP_WIDTH - 2 * MARGIN) / (maxX - minX)
|
||||
return {
|
||||
width: MAP_WIDTH,
|
||||
height: 2 * MARGIN + (maxY - minY) * scale,
|
||||
x: (v) => Math.round((MARGIN + (v - minX) * scale) * PRECISION),
|
||||
y: (v) => Math.round((MARGIN + (maxY - v) * scale) * PRECISION),
|
||||
}
|
||||
}
|
||||
|
||||
function pack(path) {
|
||||
const geojson = JSON.parse(readFileSync(path, 'utf8'))
|
||||
const collected = collect(geojson.features ?? [])
|
||||
const canvas = fitToCanvas(collected)
|
||||
const shapes = quantize(collected, canvas)
|
||||
|
||||
const bytes = [0x42, 0x4d, 0x41, 0x50, 0x02] // "BMAP" v2
|
||||
uvarint(bytes, PRECISION)
|
||||
uvarint(bytes, Math.round(canvas.width * PRECISION))
|
||||
uvarint(bytes, Math.round(canvas.height * PRECISION))
|
||||
uvarint(bytes, shapes.length)
|
||||
|
||||
let rings = 0
|
||||
let points = 0
|
||||
for (const shape of shapes) {
|
||||
const code = Buffer.from(shape.code, 'utf8')
|
||||
uvarint(bytes, code.length)
|
||||
for (const byte of code) bytes.push(byte)
|
||||
bytes.push(shape.isState ? 1 : 0)
|
||||
uvarint(bytes, shape.rings.length)
|
||||
|
||||
for (const { points: ring, evenOdd } of shape.rings) {
|
||||
uvarint(bytes, ring.length / 2)
|
||||
bytes.push(evenOdd ? 1 : 0)
|
||||
let px = 0
|
||||
let py = 0
|
||||
for (let i = 0; i < ring.length; i += 2) {
|
||||
svarint(bytes, ring[i] - px)
|
||||
svarint(bytes, ring[i + 1] - py)
|
||||
px = ring[i]
|
||||
py = ring[i + 1]
|
||||
}
|
||||
rings++
|
||||
points += ring.length / 2
|
||||
}
|
||||
}
|
||||
|
||||
const out = path.replace(/^.*\//, './app/src/main/assets/').replace(/\.geojson$/, '.bmap')
|
||||
writeFileSync(out, Buffer.from(bytes))
|
||||
console.log(
|
||||
`${out}: ${shapes.length} shapes, ${rings} rings, ${points} points, ` +
|
||||
`${canvas.width}x${canvas.height.toFixed(1)}, ${bytes.length} bytes`,
|
||||
)
|
||||
}
|
||||
|
||||
const inputs = process.argv.slice(2)
|
||||
if (inputs.length === 0) {
|
||||
console.error('usage: node packmap.js <map>.geojson ...')
|
||||
process.exit(1)
|
||||
}
|
||||
inputs.forEach(pack)
|
||||
@@ -6,6 +6,9 @@ pluginManagement {
|
||||
maven { url 'https://jitpack.io' }
|
||||
}
|
||||
}
|
||||
plugins {
|
||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
|
||||
Reference in New Issue
Block a user