Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38da904f4b | ||
|
|
b7d1eddfa0 | ||
|
|
7208efbba6 | ||
|
|
2414b2077f | ||
|
|
1cbf3ecc4b | ||
|
|
ccae8599eb | ||
|
|
cdd91ab7e6 | ||
|
|
5b7469ae44 | ||
|
|
3c7ea2d894 | ||
|
|
55fbfa4499 | ||
|
|
b655cd9631 | ||
|
|
bc880ef6bd | ||
|
|
7ff0c2ac69 | ||
|
|
fa6e30545a | ||
|
|
3e206268b4 | ||
|
|
659e33676a | ||
|
|
cf44b37bf4 | ||
|
|
37dadeda84 | ||
|
|
b478cbfd2a | ||
|
|
76022ff91c | ||
|
|
b918a8395b | ||
|
|
2283734210 | ||
|
|
9dfa6f7d39 | ||
|
|
3ed48336af | ||
|
|
7f03b046ab | ||
|
|
90e363f49e | ||
|
|
465481f8f1 | ||
|
|
680bf410fe | ||
|
|
1009d06a4c | ||
|
|
7a84f00060 | ||
|
|
2d71351080 | ||
|
|
b0f76a8ba1 | ||
|
|
ed062a040c | ||
|
|
0430aab78e | ||
|
|
5d526d29db | ||
|
|
09c4358626 | ||
|
|
9ce7cf3b69 | ||
|
|
3745d23339 | ||
|
|
23f324e606 | ||
|
|
44a18ec799 | ||
|
|
1e003bbdb7 | ||
|
|
421bc372ee | ||
|
|
c8c48d408f | ||
|
|
d001616767 | ||
|
|
07ee48e20c | ||
|
|
9614e3498a | ||
|
|
0f082e1301 | ||
|
|
2808bead97 | ||
|
|
e8f9e68759 | ||
|
|
f2168d339b | ||
|
|
507d1f0882 | ||
|
|
381a7fdc40 | ||
|
|
c8e2e3a29f | ||
|
|
92a3189784 | ||
|
|
0af6cdedcd | ||
|
|
ab1b45da07 | ||
|
|
ed66e9394b | ||
|
|
f56630d7d6 | ||
|
|
50e97a8e0d | ||
|
|
e0bba21ecc | ||
|
|
28f784fe88 | ||
|
|
b421da165a | ||
|
|
8b59954ce0 | ||
|
|
70ada49776 | ||
|
|
e20f32c918 | ||
|
|
71adbfd8ff | ||
|
|
cccdb354f8 | ||
|
|
4321eae647 | ||
|
|
03e71bfdf2 | ||
|
|
23323c91e0 | ||
|
|
8dbc4b7e62 | ||
|
|
e4a5d42b85 | ||
|
|
470eaf5d80 | ||
|
|
a41626955e | ||
|
|
be889681a9 | ||
|
|
f93e1c5898 | ||
|
|
2d67e3d57f | ||
|
|
1a32e4a228 | ||
|
|
b318aa66cc | ||
|
|
6cbe29fef1 | ||
|
|
96b5f2d91f | ||
|
|
ee004c1c62 | ||
|
|
b00552f617 | ||
|
|
4dbc773343 | ||
|
|
ec347e1b7a | ||
|
|
b0bbe3e402 | ||
|
|
85061ab673 | ||
|
|
ab68e4a84e | ||
|
|
f6532b2e85 | ||
|
|
8685dbd6cf | ||
|
|
b7c87def5b | ||
|
|
ae8e5b1bc6 | ||
|
|
ad69371f6a | ||
|
|
3f97084246 | ||
|
|
74dfc3a725 | ||
|
|
6442325926 | ||
|
|
4c4b3b6c2d | ||
|
|
4d8cb58550 | ||
|
|
a6172c4323 | ||
|
|
b70a679c64 | ||
|
|
f39bda110a | ||
|
|
ab8ecd65fe | ||
|
|
de0771d797 | ||
|
|
22e4266d38 | ||
|
|
e3332d1e27 | ||
|
|
5cebf163c5 | ||
|
|
cfd85ff9c3 | ||
|
|
c6f479750f | ||
|
|
386a8ad531 | ||
|
|
bcd7e409ba | ||
|
|
88f35e2c20 | ||
|
|
d2194da28e | ||
|
|
2f1d592497 | ||
|
|
2b6f1ba9d7 | ||
|
|
4a017f6290 | ||
|
|
7bab30aa71 | ||
|
|
77b95289e7 | ||
|
|
5b894b9dbc | ||
|
|
187ac9aa84 | ||
|
|
629bd8d84f | ||
|
|
7152d11007 | ||
|
|
a03d907128 | ||
|
|
91569e12f4 | ||
|
|
68f0d68980 | ||
|
|
8790b98766 | ||
|
|
8846cba40c | ||
|
|
0cc6ad686a | ||
|
|
612cb67438 | ||
|
|
cd5d8b503b | ||
|
|
aa59431403 | ||
|
|
269701410f | ||
|
|
3cb8096141 | ||
|
|
1f667e4a4b | ||
|
|
d5f7e290ab | ||
|
|
f2ba798764 | ||
|
|
d7ee87f22d | ||
|
|
2587e6bcfe | ||
|
|
755392e0fa | ||
|
|
a9fb428a0a | ||
|
|
bbb485b9cf | ||
|
|
3a94a69be3 | ||
|
|
e4028f4bcd | ||
|
|
7c10517bf5 | ||
|
|
cd4e5b1117 | ||
|
|
a13ac7d917 | ||
|
|
4e28098a4e | ||
|
|
52e98a71f4 | ||
|
|
81ff6a9b0e | ||
|
|
be52a23868 | ||
|
|
fc1561804c | ||
|
|
1288b3f998 | ||
|
|
e00488fad8 | ||
|
|
12c61dc35a | ||
|
|
0c0e8b06ba | ||
|
|
1bd3896ca7 | ||
|
|
7e6c0b4f7e | ||
|
|
2cde1a2c27 | ||
|
|
53100eb6c8 | ||
|
|
3b555219d2 | ||
|
|
ba35d4094c | ||
|
|
9bd6d988aa | ||
|
|
daabbc63c7 | ||
|
|
997bc81d5e | ||
|
|
3c59507bc3 | ||
|
|
721c43d569 | ||
|
|
25eda98612 | ||
|
|
0b909a4d63 | ||
|
|
37298afd77 | ||
|
|
5a77fdf68f | ||
|
|
31c898ad7f | ||
|
|
88c9756edf | ||
|
|
7b15b534cd | ||
|
|
8e7ecc3d51 | ||
|
|
7f9019c7bc | ||
|
|
a1a3bad682 | ||
|
|
1b746f2452 | ||
|
|
3db49e1213 | ||
|
|
605c934d33 | ||
|
|
dad762a0f1 | ||
|
|
97567d15dd | ||
|
|
fc080f3719 | ||
|
|
ce9ea10d68 | ||
|
|
dc94aa94b6 | ||
|
|
db7ac771b1 | ||
|
|
19134ea23c | ||
|
|
b12011fab7 | ||
|
|
d62cd10478 | ||
|
|
7de7cb96c3 | ||
|
|
dbc97ad61b | ||
|
|
7966b01900 | ||
|
|
f98e95fe9e | ||
|
|
392b93f089 | ||
|
|
9c459c754f | ||
|
|
ec967e29cf | ||
|
|
58e36667a7 | ||
|
|
77aba73096 | ||
|
|
da8f60a7b3 | ||
|
|
e8b5444fc2 | ||
|
|
08bc401681 | ||
|
|
7ac342e2d4 | ||
|
|
b244575937 | ||
|
|
c878742e50 | ||
|
|
13434ca36c | ||
|
|
6345710de5 | ||
|
|
0cab1c1e6b | ||
|
|
1c71495446 | ||
|
|
2c0158b75c | ||
|
|
a2b019b280 | ||
|
|
1fba7d2d57 | ||
|
|
490941f506 | ||
|
|
6bf759b6ae | ||
|
|
43e5088e96 | ||
|
|
582fa561b2 | ||
|
|
7f328d4eb5 | ||
|
|
b2eb6a4b1e | ||
|
|
c2db4c5f7e | ||
|
|
b246c4d10a | ||
|
|
6d4a110949 | ||
|
|
ffbbff80d6 | ||
|
|
bd2edf6624 | ||
|
|
6321948415 | ||
|
|
89e239d83f | ||
|
|
0e48ad7bb2 | ||
|
|
ad82c21dbc | ||
|
|
6f126802cd | ||
|
|
704b614c38 | ||
|
|
6eb6f1b339 | ||
|
|
dbd2fff121 | ||
|
|
dc6da67cbf | ||
|
|
ea6fb1c29c | ||
|
|
29267a865c | ||
|
|
e93d2d36a8 | ||
|
|
107c6fccf6 | ||
|
|
9350af9589 | ||
|
|
a9a6579a9a | ||
|
|
7795e13432 | ||
|
|
c9e98d5fdd | ||
|
|
72253f99fa | ||
|
|
f0d3bed8a2 | ||
|
|
30c9acf0d2 | ||
|
|
e3175ef6a3 | ||
|
|
441eb295c6 | ||
|
|
cb2f8aba72 | ||
|
|
7b9cda93bc | ||
|
|
8f64e3fee3 | ||
|
|
c4de5f2ea0 | ||
|
|
9172f504e5 | ||
|
|
7322e2ab66 | ||
|
|
29d79fb5d1 | ||
|
|
f6b70184a7 | ||
|
|
e4abcda94f | ||
|
|
284ee4ba7c | ||
|
|
10ef984962 | ||
|
|
bd8719ce99 | ||
|
|
5e38319379 | ||
|
|
7e7ad50f60 | ||
|
|
0f4e31853b | ||
|
|
c222468021 | ||
|
|
2fb4a4d0ce | ||
|
|
e4828482c4 | ||
|
|
8acfda4e42 | ||
|
|
db24c38e63 | ||
|
|
06868e9ed1 | ||
|
|
3ed643e072 | ||
|
|
f0f9898f7c | ||
|
|
73cae855ed | ||
|
|
de3a115ca6 | ||
|
|
1d7633484b | ||
|
|
5ee0ade54b | ||
|
|
e637f0814d | ||
|
|
1a169fdb4f | ||
|
|
3dc31697cd | ||
|
|
242a68080a | ||
|
|
8c7e1c1a3d | ||
|
|
47ddbed665 | ||
|
|
fd7ae77a61 | ||
|
|
3e794f57e0 | ||
|
|
30f74583ff | ||
|
|
f7b9aa2181 | ||
|
|
766b0f95a4 | ||
|
|
8bc4ed68d4 | ||
|
|
d534825e92 | ||
|
|
08d6492df1 | ||
|
|
a156b6ad10 | ||
|
|
3bb92f79b2 | ||
|
|
65cae0d822 | ||
|
|
b24f880d62 | ||
|
|
ef71ddb648 | ||
|
|
0143a84cee | ||
|
|
82721110ed | ||
|
|
aec363b152 | ||
|
|
9a21c936b1 | ||
|
|
d8f0fd7b4f | ||
|
|
4a942a04c3 | ||
|
|
ecae68ca79 | ||
|
|
9270b227b0 | ||
|
|
cff590fe8a | ||
|
|
4366083152 | ||
|
|
8865af35fd | ||
|
|
bfad51268b | ||
|
|
0e77e8d61f | ||
|
|
08d316b1e0 | ||
|
|
b8796369ba | ||
|
|
fa1c41388d | ||
|
|
d2cfad490a | ||
|
|
69d78ba8c0 | ||
|
|
9cf1984fb2 | ||
|
|
ee8fd32bf7 | ||
|
|
f357dad629 | ||
|
|
b3547364d4 | ||
|
|
965ed4c792 | ||
|
|
32dbfbd04e | ||
|
|
a930a4f02d | ||
|
|
f98a831195 | ||
|
|
9d21af9f7c | ||
|
|
0680181016 | ||
|
|
afd0714301 | ||
|
|
8855eaf05a | ||
|
|
2594697e00 | ||
|
|
cf5efea3e4 | ||
|
|
4689505fb9 | ||
|
|
23f425f95f | ||
|
|
2fd9845b37 | ||
|
|
e851bd97e0 | ||
|
|
b487ca1145 | ||
|
|
27b342a5e7 | ||
|
|
c1cd76bbcf | ||
|
|
4a28b51ade | ||
|
|
130c4e72e0 | ||
|
|
980c54a616 | ||
|
|
1201b44908 | ||
|
|
9c7983b13e | ||
|
|
01d0b8f22b | ||
|
|
dad71e9e4f | ||
|
|
fbe01bf19d | ||
|
|
fc79be7c05 | ||
|
|
4c2638a7e5 | ||
|
|
a501366cbf | ||
|
|
afde919e5b | ||
|
|
98c49f6bbc | ||
|
|
8b9c835ca1 | ||
|
|
37b436f543 | ||
|
|
656af9dae4 | ||
|
|
eff412a9a9 | ||
|
|
ff7227af5b | ||
|
|
3013a2ba08 | ||
|
|
de95a437bb | ||
|
|
85872f8379 | ||
|
|
55a643d9a4 | ||
|
|
949b92d7e7 | ||
|
|
37e9e288ba | ||
|
|
2b8ebd73cb | ||
|
|
f9f8380ece | ||
|
|
10e1504531 | ||
|
|
2e3464104d | ||
|
|
dc8f9c5099 | ||
|
|
08905a1680 | ||
|
|
fd939797dd | ||
|
|
a621a3ee62 | ||
|
|
ae4a56dac1 | ||
|
|
fdc2378191 | ||
|
|
5388f76733 | ||
|
|
ceb88d2e29 | ||
|
|
ec3e60e47b | ||
|
|
5c6df90526 | ||
|
|
deeb654482 | ||
|
|
8e0865a71f | ||
|
|
d4d22eb4b2 | ||
|
|
592dd08835 | ||
|
|
c6294d9eb3 | ||
|
|
848ce68ec5 | ||
|
|
95e57fb812 | ||
|
|
4441b16699 | ||
|
|
5fd3b5b688 | ||
|
|
f582bbab3a | ||
|
|
b40dc8a597 | ||
|
|
14bb92fac0 | ||
|
|
9887b2aa78 | ||
|
|
2adf252942 | ||
|
|
660653ce14 | ||
|
|
f3b607b15a | ||
|
|
ae5aa7b547 | ||
|
|
f50bbc005c | ||
|
|
e8dfa0eaaf | ||
|
|
b53c56d508 | ||
|
|
67c85be23c | ||
|
|
e41a3a4c1c | ||
|
|
e4276ff66e | ||
|
|
1e87081058 | ||
|
|
12b0a861e0 | ||
|
|
712d83a012 | ||
|
|
bd540ef18b | ||
|
|
2f2fced15c | ||
|
|
97ae05d688 | ||
|
|
df1d2767cd | ||
|
|
ed099a660e | ||
|
|
6536d140ba | ||
|
|
6a6b7ad6c2 | ||
|
|
01a23ccb77 | ||
|
|
9b8168370f | ||
|
|
b2b93e2839 | ||
|
|
f9113b6ad3 | ||
|
|
e168ab4148 | ||
|
|
e4412955f0 | ||
|
|
78eb766789 | ||
|
|
d8064cc426 | ||
|
|
bd4f1b2325 | ||
|
|
b38b9030f0 | ||
|
|
efa711af78 | ||
|
|
ea302966d5 | ||
|
|
ea5c2a56cf | ||
|
|
6b0fc48389 | ||
|
|
a27fa08e4e | ||
|
|
27d0952d01 | ||
|
|
4e9b1eebe9 | ||
|
|
0848c275d2 | ||
|
|
ea0789b191 | ||
|
|
1f94d0ef08 | ||
|
|
dd77e7f467 | ||
|
|
60dad353ce | ||
|
|
1b63483596 | ||
|
|
56c103140c | ||
|
|
b60e8285a4 | ||
|
|
0f1424335e | ||
|
|
506a1e6e4c | ||
|
|
8b7756beb4 | ||
|
|
dee4ffce2a | ||
|
|
fbf3cd116b | ||
|
|
b2165173cf | ||
|
|
912d727d7a | ||
|
|
32036a93ed | ||
|
|
7f4b0b78c0 | ||
|
|
775daa233e | ||
|
|
4fb013701b | ||
|
|
2dd68da284 | ||
|
|
65991be09d | ||
|
|
1c4697ecb2 | ||
|
|
ab89611eca | ||
|
|
b952e074f4 | ||
|
|
e01fc39abc | ||
|
|
deabb74393 | ||
|
|
803f4dc4ed | ||
|
|
20784c2fb1 | ||
|
|
83dbdf0539 | ||
|
|
c9d46cfab9 | ||
|
|
c7140fd639 | ||
|
|
c887b77b5f | ||
|
|
b3e398b8d6 | ||
|
|
f4a72da118 | ||
|
|
086581851b | ||
|
|
f8fdfab031 | ||
|
|
3f82da3c18 | ||
|
|
cd557acf81 | ||
|
|
036dc53fe8 | ||
|
|
73806cbd70 | ||
|
|
ee148732dd | ||
|
|
3014796c1d | ||
|
|
405b0e24cf | ||
|
|
778db0542e | ||
|
|
f50806fbd9 | ||
|
|
bcfb3bfd37 | ||
|
|
cb430a22ee | ||
|
|
abfed4afa2 | ||
|
|
d48420485f | ||
|
|
0cd243fe00 | ||
|
|
c49faf9a14 | ||
|
|
d01c288454 | ||
|
|
c31ab05c80 | ||
|
|
f45952d7fb | ||
|
|
e6889caae9 | ||
|
|
e3b7a35e2c | ||
|
|
1e507225dd | ||
|
|
47992b5f03 | ||
|
|
453e0df27b | ||
|
|
6ed38014ed | ||
|
|
f832d1ba07 | ||
|
|
f0e00c63a3 | ||
|
|
40ecfb9fa9 | ||
|
|
8c68a7d711 | ||
|
|
628d1e8490 | ||
|
|
0881c72c0f | ||
|
|
6da30f91c4 | ||
|
|
f833ff6d04 | ||
|
|
fba7e79a1c | ||
|
|
d829726679 | ||
|
|
c48edd7898 | ||
|
|
ffaf6fc869 | ||
|
|
4f77d51ffe | ||
|
|
a197d124dc | ||
|
|
9599c3e0b9 | ||
|
|
a81125eab1 | ||
|
|
bbe1b3ce80 | ||
|
|
5e80c27f61 | ||
|
|
7be6f64596 | ||
|
|
0bab7f05b9 | ||
|
|
dc898ce2af | ||
|
|
e5c9ca1a7d | ||
|
|
21a0d560de | ||
|
|
2941121f4b | ||
|
|
6c75ff345b | ||
|
|
d5e25244ee | ||
|
|
4095455e66 | ||
|
|
3f95763ca6 | ||
|
|
72ec3cbb97 | ||
|
|
a8bca8c316 | ||
|
|
8b75166dbe | ||
|
|
d9e03d9de3 | ||
|
|
491546c7f4 | ||
|
|
91b21a4a36 | ||
|
|
eab11db766 | ||
|
|
0c64bdcaf9 | ||
|
|
f0d7a07ed6 | ||
|
|
2aceb99948 | ||
|
|
c48294b0ca | ||
|
|
747c2b9105 | ||
|
|
724e68b38d | ||
|
|
7d664bd1de | ||
|
|
93732ee563 | ||
|
|
5293bb18c5 | ||
|
|
14ac73028e | ||
|
|
b5a88ee5a2 | ||
|
|
e81500f809 | ||
|
|
37805fe3ed | ||
|
|
7578e49573 | ||
|
|
564d97b692 | ||
|
|
99599458ff | ||
|
|
191e92ad62 | ||
|
|
f405e71d7d | ||
|
|
0924062ced | ||
|
|
8683a450a7 | ||
|
|
9f8e3c73d9 | ||
|
|
423cf1f998 | ||
|
|
c0dc1bfcc4 | ||
|
|
46d6531cf5 | ||
|
|
3ed2d957e5 | ||
|
|
5770c40d89 | ||
|
|
5e2c452753 | ||
|
|
f3f966f5b7 | ||
|
|
38c838d232 | ||
|
|
7eb734d86f | ||
|
|
b49599150c | ||
|
|
331db58705 | ||
|
|
62e20de69c | ||
|
|
480d24384b | ||
|
|
c2ff0f714a | ||
|
|
fe2affc72f | ||
|
|
be0326c862 | ||
|
|
8113557015 | ||
|
|
3def789742 | ||
|
|
92ea98bcfa | ||
|
|
15649a4e92 | ||
|
|
a6842382b2 | ||
|
|
5bf7eb45d7 | ||
|
|
09f3ddaacb | ||
|
|
ca8617c891 | ||
|
|
e2c442552b | ||
|
|
bb75c6f23f | ||
|
|
5c958572dc | ||
|
|
26b8dade20 | ||
|
|
ffd6bcf54b | ||
|
|
6c2c1f106a | ||
|
|
03d5376d01 | ||
|
|
dc14990b44 | ||
|
|
d95c998081 | ||
|
|
c5c9d355ec | ||
|
|
7470ebf47e | ||
|
|
2cd1274975 | ||
|
|
61006d8155 | ||
|
|
94cba2592e | ||
|
|
9b1faada38 | ||
|
|
e67f57fee7 | ||
|
|
a9e3a159d9 | ||
|
|
7c781bf793 | ||
|
|
4f9a366884 | ||
|
|
db87ef39c0 | ||
|
|
3ce930e9cc | ||
|
|
aca4dc5678 | ||
|
|
c495dced0f | ||
|
|
1b736272c9 | ||
|
|
05fc8ad80c | ||
|
|
4e88bdfaa7 | ||
|
|
30143b01bf | ||
|
|
fbd62fda30 | ||
|
|
ab0db0ef3a | ||
|
|
380472bda8 | ||
|
|
4e2a5877d6 | ||
|
|
d44a971acf | ||
|
|
fabd1becce | ||
|
|
345be94c65 | ||
|
|
d0c6e420d2 | ||
|
|
f0026b8bd3 | ||
|
|
2007479da6 | ||
|
|
4ba5569ca8 | ||
|
|
dc0b00b474 | ||
|
|
2695747db6 | ||
|
|
eba54344b0 | ||
|
|
93879a0ce1 | ||
|
|
8fccfa29b1 | ||
|
|
784d809290 | ||
|
|
700b688fc0 | ||
|
|
03598cfaff | ||
|
|
ed517eb8d3 | ||
|
|
1cfba19ed2 | ||
|
|
b18f5c491e | ||
|
|
741364bf99 | ||
|
|
d0f06d9670 | ||
|
|
8e49d37cbd | ||
|
|
b2382a6135 | ||
|
|
f8fb57210f | ||
|
|
8762131248 | ||
|
|
e5f79b18c2 | ||
|
|
1605e30f60 | ||
|
|
6aff322be6 | ||
|
|
ed3c132f70 | ||
|
|
385e8c4c86 | ||
|
|
61e5736f1e | ||
|
|
6ca808a6c9 | ||
|
|
f698096f53 | ||
|
|
ef628e066a | ||
|
|
4ffdfc2548 | ||
|
|
83e7727aab | ||
|
|
1348dc2177 | ||
|
|
57ac382f91 | ||
|
|
858c04d267 | ||
|
|
2484cd2515 | ||
|
|
62256942a1 | ||
|
|
3c8aadf41f | ||
|
|
2f5fd63afd | ||
|
|
4f3c3edaee | ||
|
|
7d33379fe1 | ||
|
|
82a49150d2 | ||
|
|
1ef9e553f7 | ||
|
|
39fab68bd4 | ||
|
|
bbddba6e74 | ||
|
|
b659f6f16d | ||
|
|
b4ad12bdc6 | ||
|
|
5dd3532074 | ||
|
|
dd7b63c059 | ||
|
|
8fc6673611 | ||
|
|
d33291b521 | ||
|
|
1b32b1ddec | ||
|
|
65bbaa85f3 | ||
|
|
99d903eb44 | ||
|
|
13515c58c0 | ||
|
|
e4014e11d2 | ||
|
|
025472820b | ||
|
|
8b66030c89 | ||
|
|
d4f23a52af | ||
|
|
b4b9674d8c | ||
|
|
f7d2de0777 | ||
|
|
344e8b2091 | ||
|
|
c5e0ec9033 | ||
|
|
210a99983e | ||
|
|
2a6e09a1d7 | ||
|
|
1b7f8ec9c1 | ||
|
|
75a2f73992 | ||
|
|
deee8c4618 | ||
|
|
92a5f30ac0 | ||
|
|
d3a405f4f6 | ||
|
|
f7b9bfd0bc | ||
|
|
102097c5c4 | ||
|
|
3b90ef6b7a |
@@ -1,6 +1,6 @@
|
|||||||
# OpsLog
|
# OpsLog
|
||||||
|
|
||||||
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
<a href="https://discord.gg/tmVPdQ5A8" target="_blank">
|
||||||
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
|
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -39,6 +39,9 @@ Developed by **F4BPO**.
|
|||||||
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
||||||
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
||||||
ClubLog DXpedition date overrides.
|
ClubLog DXpedition date overrides.
|
||||||
|
- **Editable lists** for bands, modes (with their default RST) and **satellites**
|
||||||
|
— the satellite name on the entry form is offered as a dropdown, because
|
||||||
|
SAT_NAME is compared character for character by the awards and by LoTW.
|
||||||
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
||||||
from cty/QRZ/ClubLog, bulk send to QSL services. A live **selection count**, a
|
from cty/QRZ/ClubLog, bulk send to QSL services. A live **selection count**, a
|
||||||
**Select all / Unselect all** toggle, and a row limit that keeps the full log
|
**Select all / Unselect all** toggle, and a row limit that keeps the full log
|
||||||
@@ -57,11 +60,13 @@ Developed by **F4BPO**.
|
|||||||
|
|
||||||
- **Main view = two configurable panes** (per profile, Settings → General →
|
- **Main view = two configurable panes** (per profile, Settings → General →
|
||||||
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
||||||
worked-before grid, recent QSOs, the **FlexRadio controls**, the **Icom
|
worked-before grid, recent QSOs, the **FT decodes**, or any of the radio
|
||||||
console** or the **Net control** panel.
|
consoles — **FlexRadio**, **Icom**, **Yaesu**, **Elecraft K3/K4**, **SunSDR
|
||||||
|
(TCI)** — and the **Net control** panel. Up to four panes, in columns or a
|
||||||
|
quadrant.
|
||||||
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||||
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
basemaps (Light / Topo / Street / Satellite — Esri tiles, key-free and
|
||||||
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
labelled) and the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||||
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by
|
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by
|
||||||
**PstRotator** (UDP), a **4O3A Rotator Genius** (native TCP) or a **microHAM
|
**PstRotator** (UDP), a **4O3A Rotator Genius** (native TCP) or a **microHAM
|
||||||
ARCO** controlled natively — no PstRotator needed — over the **LAN** or **USB**
|
ARCO** controlled natively — no PstRotator needed — over the **LAN** or **USB**
|
||||||
@@ -87,8 +92,10 @@ Developed by **F4BPO**.
|
|||||||
|
|
||||||
## CAT control
|
## CAT control
|
||||||
|
|
||||||
Four native backends (Settings → CAT), each with auto-reconnect and a fast,
|
Six native backends (Settings → CAT), each with auto-reconnect and a fast,
|
||||||
non-blocking connect so a powered-off radio never freezes the app:
|
non-blocking connect so a powered-off radio never freezes the app. The settings
|
||||||
|
ask two questions rather than one — **which radio**, then **how it is
|
||||||
|
connected** — and only offer the links that radio actually has:
|
||||||
|
|
||||||
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
||||||
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
||||||
@@ -98,8 +105,17 @@ non-blocking connect so a powered-off radio never freezes the app:
|
|||||||
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
|
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
|
||||||
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
||||||
Remote Utility needed.
|
Remote Utility needed.
|
||||||
- **TCI** (WebSocket) — SunSDR / ExpertSDR2 and any TCI-compatible server:
|
- **Kenwood / Elecraft** — native ASCII CAT over USB or an RS232-to-Ethernet
|
||||||
freq / mode / PTT / split, plus optional panorama spots.
|
bridge, for the K3 / K4 and the Kenwood dialect they share.
|
||||||
|
- **Yaesu** — native ASCII CAT (FA/FB/MD/VS…), which replaces OmniRig for an
|
||||||
|
FTDX and gives the Yaesu console below.
|
||||||
|
- **TCI** (WebSocket) — SunSDR / ExpertSDR3 and any TCI-compatible server:
|
||||||
|
freq / mode / PTT / split, panorama spots, **audio in both directions** and a
|
||||||
|
full console. See *TCI audio* below.
|
||||||
|
|
||||||
|
**CAT sharing:** OpsLog can hand the radio on to other programs while it holds
|
||||||
|
the port — a **Hamlib NET rigctl** server (WSJT-X, JTDX, MSHV, fldigi…) or a
|
||||||
|
**TCI** server. One or the other, on a port you choose.
|
||||||
|
|
||||||
Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred from
|
Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred from
|
||||||
the frequency. **Per-band Flex RX/TX antennas** can be configured and are applied
|
the frequency. **Per-band Flex RX/TX antennas** can be configured and are applied
|
||||||
@@ -124,6 +140,32 @@ Shown only when the CAT backend is a FlexRadio:
|
|||||||
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
||||||
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
||||||
|
|
||||||
|
### Elecraft K3 / K4 control tab
|
||||||
|
|
||||||
|
Shown for the Elecraft and Kenwood backends: power, AF/RF gain, mic gain,
|
||||||
|
squelch, preamp/attenuator, NB/NR/AGC, filter widths (200 Hz to 4.0 kHz, the
|
||||||
|
K3 maximum and the one FT8 wants), antenna selection, MOX and the ATU
|
||||||
|
(tap to tune, hold for in/out), RIT/XIT with ±10 / ±100 steps, key speed, and
|
||||||
|
live S / power / SWR meters. The SWR is read from the radio's own `SW;`.
|
||||||
|
|
||||||
|
### Yaesu control tab
|
||||||
|
|
||||||
|
Shown for the Yaesu backend: power, mic gain, AGC, NB/NR, preamp/attenuator,
|
||||||
|
filter width, RIT/XIT and the meters — the same shape as the others, driven by
|
||||||
|
the FTDX's own CAT set.
|
||||||
|
|
||||||
|
### SunSDR console (TCI)
|
||||||
|
|
||||||
|
Shown for the TCI backend. Drive and tune drive, mic gain, TUNE, power and SWR
|
||||||
|
while transmitting, volume, mute, squelch and its threshold, NB / NR / ANF,
|
||||||
|
APF in CW, AGC speed, filter widths **offered per mode**, RIT and XIT, and the
|
||||||
|
VFO lock. Levels can be dragged, scrolled, stepped or typed. The S-meter reads
|
||||||
|
real dBm, so its S-units are arithmetic rather than a calibration guess.
|
||||||
|
|
||||||
|
It costs nothing to keep open: TCI **pushes** every setting as it changes —
|
||||||
|
including changes made in the radio's own window, which the console follows
|
||||||
|
without asking anything.
|
||||||
|
|
||||||
### Icom control tab
|
### Icom control tab
|
||||||
|
|
||||||
Shown when the CAT backend is Icom (USB or network). A full RS-BA1-style console:
|
Shown when the CAT backend is Icom (USB or network). A full RS-BA1-style console:
|
||||||
@@ -169,6 +211,17 @@ as Mumble.)
|
|||||||
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
||||||
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
||||||
|
|
||||||
|
### TCI audio — no virtual cable
|
||||||
|
|
||||||
|
A SunSDR carries its audio on the same WebSocket as its commands, so OpsLog can
|
||||||
|
take it directly. Pick **Radio (TCI network audio)** as the *From radio* or
|
||||||
|
*To radio* device and the QSO recorder and the voice keyer work with no virtual
|
||||||
|
audio cable, no second sound card and nothing to set in the Windows mixer.
|
||||||
|
|
||||||
|
Transmit needs no setting up in ExpertSDR3 either: the audio source is named on
|
||||||
|
each key-down, so it works in SSB as well as the digital modes, and the
|
||||||
|
microphone stays the source for every transmission that is not a voice message.
|
||||||
|
|
||||||
## Amplifiers & switches
|
## Amplifiers & switches
|
||||||
|
|
||||||
- **Amplifiers** — configure **one or several** amps (Settings → Amplifier is a
|
- **Amplifiers** — configure **one or several** amps (Settings → Amplifier is a
|
||||||
@@ -182,12 +235,24 @@ as Mumble.)
|
|||||||
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
|
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
|
||||||
Low / Mid / High output level, an output-power bar and live status (band,
|
Low / Mid / High output level, an output-power bar and live status (band,
|
||||||
SWR, PA current, temperature, warnings/alarms).
|
SWR, PA current, temperature, warnings/alarms).
|
||||||
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) over **USB** or the **network**
|
- **Acom** (500S / 600S / 700S / 1200S / 2020S) over **USB** or the **network**
|
||||||
(RS232-to-Ethernet bridge) — operate/standby/off and live telemetry (forward
|
(RS232-to-Ethernet bridge) — operate/standby/off and live telemetry (forward
|
||||||
& reflected power, SWR, PA temperature, band, fan, faults). Power-ON works
|
& reflected power, SWR, PA temperature, band, fan, faults). Power-ON works
|
||||||
over a serial cable that wires the DTR/RTS lines.
|
over a serial cable that wires the DTR/RTS lines.
|
||||||
|
- **Elecraft KPA500 / KPA1500** over **USB** (serial) or — on the KPA1500 —
|
||||||
|
over the **network**. Operate/standby, on/off, forward power, SWR,
|
||||||
|
temperature, supply voltage and current, the band, and faults named in plain
|
||||||
|
words rather than as a code. The amplifier follows the radio's band by
|
||||||
|
itself, on the link already open — it has a band command of its own, so no
|
||||||
|
second serial port is involved.
|
||||||
|
|
||||||
|
An amplifier that takes its band from a transceiver instead (Acom, SPE) can be
|
||||||
|
followed a second way: OpsLog answers its frequency polls as a Kenwood rig on
|
||||||
|
a **second COM port**, independent of the control link.
|
||||||
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||||
antenna-switch widget.
|
antenna-switch widget, and an option to write the **selected antenna into
|
||||||
|
MY_ANTENNA** under the name the switch itself carries.
|
||||||
|
- **Tuner Genius XL** (4O3A) over TCP — SWR and power, TUNE / BYPASS / OPERATE.
|
||||||
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
|
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
|
||||||
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
|
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
|
||||||
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
|
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
|
||||||
@@ -204,8 +269,18 @@ as Mumble.)
|
|||||||
reference assignment, live reference detection on call entry, **reference-list
|
reference assignment, live reference detection on call entry, **reference-list
|
||||||
import** for totals/names, and a **Rescan** that re-pulls the logbook (picks up
|
import** for totals/names, and a **Rescan** that re-pulls the logbook (picks up
|
||||||
fresh LoTW/QRZ confirmations).
|
fresh LoTW/QRZ confirmations).
|
||||||
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
|
- **Award statistics** by band and by mode class, with the **DXCC Challenge**
|
||||||
and **confirmation download** (which auto-refreshes the award stats).
|
worked out where it can be compared to LoTW's own figure — it counts confirmed
|
||||||
|
band-slots on ten bands, and 60 m is not one of them, which is the whole of the
|
||||||
|
usual discrepancy. The band columns follow the bands you actually have contacts
|
||||||
|
on, up to and beyond 23 cm.
|
||||||
|
- **Russian districts (RDA):** an offline district database that knows where a
|
||||||
|
callsign was **on the day of the contact**, a bulk fill for existing QSOs, and
|
||||||
|
a comparison against the district an imported log carries — each disagreement
|
||||||
|
settled on its own row, with the choice written into the contact.
|
||||||
|
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL,
|
||||||
|
**Cloudlog / Wavelog** and **HAMLOG.online** — upload and **confirmation
|
||||||
|
download** (which auto-refreshes the award stats).
|
||||||
- **QSL Card Designer** (see below).
|
- **QSL Card Designer** (see below).
|
||||||
- **E-mail eQSL:** right-click a QSO → *Send eQSL by e-mail* via the configured
|
- **E-mail eQSL:** right-click a QSO → *Send eQSL by e-mail* via the configured
|
||||||
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
|
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
|
||||||
@@ -266,6 +341,10 @@ only writes to the DB — it is not a web server.
|
|||||||
- **UDP emitters:** push the current frequency to **PstRotator**, radio info in
|
- **UDP emitters:** push the current frequency to **PstRotator**, radio info in
|
||||||
**N1MM `RadioInfo`** format, or an **ADIF record on each logged QSO** — so
|
**N1MM `RadioInfo`** format, or an **ADIF record on each logged QSO** — so
|
||||||
external tools (rotator control, digital apps, other loggers) stay in sync.
|
external tools (rotator control, digital apps, other loggers) stay in sync.
|
||||||
|
- **CAT sharing:** a **Hamlib NET rigctl** or **TCI** server, so WSJT-X, JTDX,
|
||||||
|
MSHV, fldigi and the rest still reach the radio while OpsLog holds the port.
|
||||||
|
- **WSJT-X / JTDX / MSHV** decodes over UDP: the heard stations feed the band
|
||||||
|
map, the alerts and — on a FlexRadio — the panadapter.
|
||||||
|
|
||||||
## Other
|
## Other
|
||||||
|
|
||||||
@@ -309,6 +388,19 @@ commonly-worked DXCC entities.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Importing a log
|
||||||
|
|
||||||
|
- **ADIF import** with duplicate handling (skip / update / import anyway) and an
|
||||||
|
optional field mapping for exports that use their own names.
|
||||||
|
- Optionally recompute country / continent / DXCC / zones from **cty.dat** and
|
||||||
|
the **ClubLog date-ranged exceptions** — which know that a callsign belonged to
|
||||||
|
a different entity on an older date.
|
||||||
|
- **Deleted DXCC entities are left alone.** cty.dat only knows where a callsign
|
||||||
|
is *today*: R1MVI resolves to European Russia now, but a 2004 contact was Malyj
|
||||||
|
Vysotskij — an entity the ARRL deleted in 2012 and which can never be worked
|
||||||
|
again. Where a record names one of the sixty-odd deleted entities, its own
|
||||||
|
number is kept.
|
||||||
|
|
||||||
## Data & storage
|
## Data & storage
|
||||||
|
|
||||||
- **Config** (settings, profiles, rigs/antennas, cluster nodes, lookup cache,
|
- **Config** (settings, profiles, rigs/antennas, cluster nodes, lookup cache,
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The coupling is a SET, not a global switch.
|
||||||
|
//
|
||||||
|
// A station can run a combiner pair AND a third amplifier that has nothing to
|
||||||
|
// do with it — two SPE on the combiner, a PowerGenius on another antenna. A
|
||||||
|
// global flag would send that third one into OPERATE alongside them.
|
||||||
|
func TestAmpTargetsFollowTheGroup(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.ampInsts = map[string]*ampInst{"spe1": {}, "spe2": {}, "pgxl": {}}
|
||||||
|
group := []string{"spe1", "spe2"}
|
||||||
|
|
||||||
|
// A member commands the whole group, itself first.
|
||||||
|
got := a.ampTargets("spe2", group)
|
||||||
|
if len(got) != 2 || got[0] != "spe2" || got[1] != "spe1" {
|
||||||
|
t.Errorf("member = %v, want [spe2 spe1] — the clicked one first", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The amplifier OUTSIDE the group keeps its buttons to itself.
|
||||||
|
if got := a.ampTargets("pgxl", group); len(got) != 1 || got[0] != "pgxl" {
|
||||||
|
t.Errorf("outsider = %v, want just [pgxl]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No group at all: everyone is on their own.
|
||||||
|
if got := a.ampTargets("spe1", nil); len(got) != 1 || got[0] != "spe1" {
|
||||||
|
t.Errorf("no group = %v, want just [spe1]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A group remembering an amplifier that is gone must not carry it: it would
|
||||||
|
// fail the command for a member that no longer exists.
|
||||||
|
if got := a.ampTargets("spe1", []string{"spe1", "deleted"}); len(got) != 1 || got[0] != "spe1" {
|
||||||
|
t.Errorf("stale member = %v, want it dropped", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One amplifier coupled to itself is not a group — storing it would make every
|
||||||
|
// command fan out to a single member for ever, which is just noise.
|
||||||
|
func TestLinkedAmpsNeedsTwo(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
in []string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{[]string{"a", "b"}, 2},
|
||||||
|
{[]string{"a"}, 0},
|
||||||
|
{[]string{" ", "a"}, 0}, // blanks are not members
|
||||||
|
{nil, 0},
|
||||||
|
} {
|
||||||
|
clean := make([]string, 0, len(tc.in))
|
||||||
|
for _, id := range tc.in {
|
||||||
|
if id != "" && id != " " {
|
||||||
|
clean = append(clean, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(clean) < 2 {
|
||||||
|
clean = nil
|
||||||
|
}
|
||||||
|
if len(clean) != tc.want {
|
||||||
|
t.Errorf("SetLinkedAmps(%v) would keep %d, want %d", tc.in, len(clean), tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two combined amplifiers must be commanded AT THE SAME TIME, not one after the
|
||||||
|
// other.
|
||||||
|
//
|
||||||
|
// Sequentially, the second was commanded only once the first had answered — and
|
||||||
|
// an SPE answers over its own link, in its own time. The combiner heard power
|
||||||
|
// appear on one input before the other and beeped about it, on every OFF and
|
||||||
|
// every ON. This is what an operator hears, so it is worth a test that would
|
||||||
|
// hear it too.
|
||||||
|
func TestLinkedAmpCommandsLeaveTogether(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
const slow = 150 * time.Millisecond
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
starts := map[string]time.Time{}
|
||||||
|
err := a.ampFanOut([]string{"one", "two"}, func(id string) error {
|
||||||
|
mu.Lock()
|
||||||
|
starts[id] = time.Now()
|
||||||
|
mu.Unlock()
|
||||||
|
time.Sleep(slow) // an amplifier taking its time to answer
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fan-out: %v", err)
|
||||||
|
}
|
||||||
|
if len(starts) != 2 {
|
||||||
|
t.Fatalf("%d amplifiers were commanded, want both", len(starts))
|
||||||
|
}
|
||||||
|
// Both goroutines wait on one channel and are released by closing it, so the
|
||||||
|
// gap is scheduling noise. Sequential execution would put a full command
|
||||||
|
// between them.
|
||||||
|
gap := starts["one"].Sub(starts["two"])
|
||||||
|
if gap < 0 {
|
||||||
|
gap = -gap
|
||||||
|
}
|
||||||
|
if gap > slow/3 {
|
||||||
|
t.Errorf("the two amplifiers were commanded %v apart — the combiner hears that as one input arriving late", gap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The amplifier the operator clicked comes first, and its failure is the one
|
||||||
|
// reported: "the amp I pressed did not respond" beats the same message about
|
||||||
|
// its silent partner.
|
||||||
|
func TestLinkedAmpErrorNamesTheOneClicked(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
clicked := errors.New("the one clicked")
|
||||||
|
other := errors.New("the other one")
|
||||||
|
err := a.ampFanOut([]string{"clicked", "other"}, func(id string) error {
|
||||||
|
if id == "clicked" {
|
||||||
|
return clicked
|
||||||
|
}
|
||||||
|
return other
|
||||||
|
})
|
||||||
|
if !errors.Is(err, clicked) {
|
||||||
|
t.Errorf("fan-out reported %v, want the amplifier the operator pressed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One amplifier is the ordinary case and must not change: run inline, no
|
||||||
|
// goroutine, no barrier, and the error straight back.
|
||||||
|
func TestSingleAmpRunsInline(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
boom := errors.New("not running")
|
||||||
|
if err := a.ampFanOut([]string{"solo"}, func(string) error { return boom }); !errors.Is(err, boom) {
|
||||||
|
t.Errorf("a single amplifier reported %v, want the error itself", err)
|
||||||
|
}
|
||||||
|
if err := a.ampFanOut(nil, func(string) error { return boom }); err != nil {
|
||||||
|
t.Errorf("an empty group reported %v, want nothing to do", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// MY_ANTENNA from the Antenna Genius.
|
||||||
|
//
|
||||||
|
// A station with a switch has a truth the log does not: which antenna is
|
||||||
|
// actually connected right now. The working conditions hold what was PLANNED
|
||||||
|
// for the band — the default antenna ticked for 20 m — and that is right until
|
||||||
|
// the operator throws the switch, at which point the log quietly keeps claiming
|
||||||
|
// the other antenna for the rest of the session.
|
||||||
|
//
|
||||||
|
// So, as an option: when the Antenna Genius knows which antenna is selected,
|
||||||
|
// its name wins. The name is the one configured on the device, because that is
|
||||||
|
// the name the operator gave it and the one they will look for in the log.
|
||||||
|
//
|
||||||
|
// WHICH PORT is the whole difficulty. An Antenna Genius has two, A and B, and a
|
||||||
|
// FlexRadio has two transmit antenna jacks, ANT1 and ANT2. The QSO was made on
|
||||||
|
// exactly one of them, and stamping the wrong port's antenna would be worse
|
||||||
|
// than stamping the band default — a wrong answer that looks authoritative. The
|
||||||
|
// radio's own TX antenna selection is what decides.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/antgenius"
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyAntGeniusMyAnt = "antgenius.my_antenna" // use the selected antenna as MY_ANTENNA
|
||||||
|
keyAntGeniusPortForA1 = "antgenius.port_for_ant1" // which AG port ANT1 is wired to (1=A, 2=B)
|
||||||
|
)
|
||||||
|
|
||||||
|
// antGeniusPortFor maps the radio's transmit antenna jack to an Antenna Genius
|
||||||
|
// port.
|
||||||
|
//
|
||||||
|
// The wiring is the station's, not something that can be read from either
|
||||||
|
// device: ANT1 usually goes to port A and ANT2 to port B, which is what the
|
||||||
|
// setting defaults to, but a station wired the other way round would otherwise
|
||||||
|
// log every QSO with the other antenna's name.
|
||||||
|
func (a *App) antGeniusPortFor(txAnt string) int {
|
||||||
|
ant1Port := 1
|
||||||
|
if a.settingOr(keyAntGeniusPortForA1, "1") == "2" {
|
||||||
|
ant1Port = 2
|
||||||
|
}
|
||||||
|
other := 3 - ant1Port
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(txAnt)) {
|
||||||
|
case "ANT1", "ANT 1", "1", "A":
|
||||||
|
return ant1Port
|
||||||
|
case "ANT2", "ANT 2", "2", "B":
|
||||||
|
return other
|
||||||
|
}
|
||||||
|
return 0 // XVTR, a rig with one jack, or nothing reported: no port to name
|
||||||
|
}
|
||||||
|
|
||||||
|
// antGeniusAntennaName returns the name of the antenna currently selected on
|
||||||
|
// the port the radio is transmitting through, or "" when it cannot be told.
|
||||||
|
//
|
||||||
|
// Deliberately silent rather than approximate. Every "" here means the log
|
||||||
|
// keeps the band default it would have had anyway, which is a defensible
|
||||||
|
// answer; a guessed port is not.
|
||||||
|
func (a *App) antGeniusAntennaName() string {
|
||||||
|
if a.antgenius == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
st := a.antgenius.GetStatus()
|
||||||
|
if !st.Connected {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// The jack in use comes from the FlexRadio's own state — TXAnt lives on the
|
||||||
|
// Flex panel state, not on the backend-agnostic RigState, because only a
|
||||||
|
// Flex has named antenna jacks to report.
|
||||||
|
txAnt := ""
|
||||||
|
if a.cat != nil {
|
||||||
|
if fs, ok := a.cat.FlexState(); ok {
|
||||||
|
txAnt = fs.TXAnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
port := a.antGeniusPortFor(txAnt)
|
||||||
|
if port == 0 {
|
||||||
|
// One switch port in use and no ambiguity about which: a station whose
|
||||||
|
// radio has a single jack still deserves the name. Two ports carrying
|
||||||
|
// different antennas with nothing to choose between them does not.
|
||||||
|
if st.PortA > 0 && st.PortB == 0 {
|
||||||
|
port = 1
|
||||||
|
} else if st.PortB > 0 && st.PortA == 0 {
|
||||||
|
port = 2
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx := st.PortA
|
||||||
|
if port == 2 {
|
||||||
|
idx = st.PortB
|
||||||
|
}
|
||||||
|
if idx <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return antGeniusNameOf(st, idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// antGeniusNameOf looks an antenna index up in the device's own list.
|
||||||
|
func antGeniusNameOf(st antgenius.Status, idx int) string {
|
||||||
|
for _, ant := range st.Antennas {
|
||||||
|
if ant.Index == idx {
|
||||||
|
return strings.TrimSpace(ant.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAntGeniusAntenna overrides MY_ANTENNA with the switch's selection, when
|
||||||
|
// the option is on.
|
||||||
|
//
|
||||||
|
// Called at log time rather than while the entry form is open: an operator who
|
||||||
|
// changes antenna mid-QSO is telling us what the contact was actually made on,
|
||||||
|
// and the value that matters is the one at the moment it is logged.
|
||||||
|
func (a *App) applyAntGeniusAntenna(myAntenna *string) {
|
||||||
|
if myAntenna == nil || a.settingOr(keyAntGeniusMyAnt, "") != "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := a.antGeniusAntennaName()
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *myAntenna != name {
|
||||||
|
applog.Printf("antgenius: MY_ANTENNA %q → %q (the antenna selected on the switch)", *myAntenna, name)
|
||||||
|
}
|
||||||
|
*myAntenna = name
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/antgenius"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The switch has two ports and the radio two antenna jacks; a QSO was made
|
||||||
|
// through exactly one of them. Naming the wrong port's antenna would be worse
|
||||||
|
// than naming the band default — it is a wrong answer that looks authoritative.
|
||||||
|
func TestAntennaNameComesFromTheDeviceList(t *testing.T) {
|
||||||
|
st := antgenius.Status{
|
||||||
|
Connected: true,
|
||||||
|
PortA: 2,
|
||||||
|
PortB: 5,
|
||||||
|
Antennas: []antgenius.Antenna{
|
||||||
|
{Index: 2, Name: "OB11-5 20/15/10"},
|
||||||
|
{Index: 5, Name: "Vertical 80m"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if got := antGeniusNameOf(st, 2); got != "OB11-5 20/15/10" {
|
||||||
|
t.Fatalf("port A antenna = %q", got)
|
||||||
|
}
|
||||||
|
if got := antGeniusNameOf(st, 5); got != "Vertical 80m" {
|
||||||
|
t.Fatalf("port B antenna = %q", got)
|
||||||
|
}
|
||||||
|
// An index the device never described has no name to give, and inventing
|
||||||
|
// one ("Antenna 7") would put a label in the log that exists nowhere else.
|
||||||
|
if got := antGeniusNameOf(st, 7); got != "" {
|
||||||
|
t.Fatalf("unknown index produced %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The jack-to-port wiring is the station's own. Defaulting ANT1 to port A is a
|
||||||
|
// convention, not a fact, so it is a setting — and the mapping has to hold both
|
||||||
|
// ways round.
|
||||||
|
func TestJackToPortMapping(t *testing.T) {
|
||||||
|
app := &App{}
|
||||||
|
// No settings store: settingOr falls back, which is ANT1 → port A.
|
||||||
|
if got := app.antGeniusPortFor("ANT1"); got != 1 {
|
||||||
|
t.Fatalf("ANT1 mapped to port %d, want A(1)", got)
|
||||||
|
}
|
||||||
|
if got := app.antGeniusPortFor("ANT2"); got != 2 {
|
||||||
|
t.Fatalf("ANT2 mapped to port %d, want B(2)", got)
|
||||||
|
}
|
||||||
|
// A jack that is neither — a transverter port, or a rig that reports
|
||||||
|
// nothing — has no port to name, and saying so beats guessing.
|
||||||
|
if got := app.antGeniusPortFor("XVTR"); got != 0 {
|
||||||
|
t.Fatalf("XVTR mapped to port %d, want none", got)
|
||||||
|
}
|
||||||
|
if got := app.antGeniusPortFor(""); got != 0 {
|
||||||
|
t.Fatalf("an unreported jack mapped to port %d, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/dxped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── DXpeditions (ADXO announcements + DX-World news) ────────────────────
|
||||||
|
//
|
||||||
|
// What a logger can say that a news reader cannot: whether the announced
|
||||||
|
// operation is worth chasing. Every activation is judged against THIS log —
|
||||||
|
// the same verdict the cluster paints on a spot — so the list reads as "what I
|
||||||
|
// still need", not "what is on the air".
|
||||||
|
|
||||||
|
// DXpedition is one announced operation plus what it is worth here.
|
||||||
|
type DXpedition struct {
|
||||||
|
dxped.Activation
|
||||||
|
// Status is the strongest verdict across the announced callsigns, bands and
|
||||||
|
// modes: "new" (entity never worked) beats "new-band-mode", which beats
|
||||||
|
// "new-band", "new-mode", "new-slot", and finally "worked". Empty when the
|
||||||
|
// callsign resolves to no entity — a prefix ADXO knows and cty.dat does not.
|
||||||
|
Status string `json:"status_chase"`
|
||||||
|
// Unconfirmed marks a need that is only a missing QSL, so the badge can be
|
||||||
|
// drawn dimmed exactly as it is in the cluster and the decode list.
|
||||||
|
Unconfirmed bool `json:"unconfirmed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// chaseRank orders the verdicts from "most worth chasing" down. The DXpedition
|
||||||
|
// list shows ONE badge, so the ranking is the whole decision.
|
||||||
|
var chaseRank = map[string]int{
|
||||||
|
"new": 6,
|
||||||
|
"new-band-mode": 5,
|
||||||
|
"new-band": 4,
|
||||||
|
"new-mode": 3,
|
||||||
|
"new-slot": 2,
|
||||||
|
"worked": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDXpeditions returns the announced operations, freshest feed permitting,
|
||||||
|
// each carrying its chase verdict.
|
||||||
|
func (a *App) GetDXpeditions() ([]DXpedition, error) {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
acts, err := a.dxped.Activations(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("dxped: adxo fetch: %v", err)
|
||||||
|
if len(acts) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Stale data with a logged error beats an empty tab.
|
||||||
|
}
|
||||||
|
out := make([]DXpedition, 0, len(acts))
|
||||||
|
for _, act := range acts {
|
||||||
|
out = append(out, DXpedition{Activation: act, Status: "", Unconfirmed: false})
|
||||||
|
}
|
||||||
|
a.judgeDXpeditions(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeDXpeditions fills in the chase verdict, in place.
|
||||||
|
//
|
||||||
|
// One ClusterSpotStatuses call for the whole list rather than one per row: the
|
||||||
|
// worked-index it builds is the expensive part (a full pass over the log), and
|
||||||
|
// asking it forty times to answer forty rows was the difference between a tab
|
||||||
|
// that opens and a tab that stalls a remote MySQL for a second.
|
||||||
|
func (a *App) judgeDXpeditions(list []DXpedition) {
|
||||||
|
if a.qso == nil || len(list) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type slot struct{ row int }
|
||||||
|
var queries []SpotQuery
|
||||||
|
var owners []slot
|
||||||
|
for i, d := range list {
|
||||||
|
calls := d.Calls
|
||||||
|
if len(calls) == 0 {
|
||||||
|
if c := strings.ToUpper(strings.TrimSpace(d.Callsign)); c != "" {
|
||||||
|
calls = []string{c}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, call := range calls {
|
||||||
|
// No announced band/mode: ask the entity-level question alone.
|
||||||
|
if len(d.Bands) == 0 && len(d.Modes) == 0 {
|
||||||
|
queries = append(queries, SpotQuery{Call: call})
|
||||||
|
owners = append(owners, slot{i})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bands := d.Bands
|
||||||
|
if len(bands) == 0 {
|
||||||
|
bands = []string{""}
|
||||||
|
}
|
||||||
|
modes := d.Modes
|
||||||
|
if len(modes) == 0 {
|
||||||
|
modes = []string{""}
|
||||||
|
}
|
||||||
|
for _, b := range bands {
|
||||||
|
for _, m := range modes {
|
||||||
|
queries = append(queries, SpotQuery{Call: call, Band: b, Mode: m})
|
||||||
|
owners = append(owners, slot{i})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(queries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := a.ClusterSpotStatuses(queries)
|
||||||
|
for i, r := range res {
|
||||||
|
if i >= len(owners) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
row := owners[i].row
|
||||||
|
if chaseRank[r.Status] > chaseRank[list[row].Status] {
|
||||||
|
list[row].Status = r.Status
|
||||||
|
list[row].Unconfirmed = r.UnconfStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDXWorldNews returns the DX-World headlines, with the callsigns mined out
|
||||||
|
// of each one so the reader can watch them.
|
||||||
|
//
|
||||||
|
// Deliberately NOT judged against the log. A headline names no band, so the
|
||||||
|
// only verdict available is entity-level, and a pane of "NEW DXCC" and
|
||||||
|
// "worked" badges turned out to say nothing an operator could act on — the
|
||||||
|
// same two words on every row is noise wearing the clothes of information.
|
||||||
|
// The announcements pane, which knows the bands and modes, is where a chase
|
||||||
|
// verdict is worth drawing.
|
||||||
|
func (a *App) GetDXWorldNews() ([]dxped.News, error) {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
news, err := a.dxped.News(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("dxped: dx-world fetch: %v", err)
|
||||||
|
if len(news) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return news, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshDXpeditions drops both caches so the next read goes to the network.
|
||||||
|
// Wired to the tab's refresh button: an operator who has just read of a landing
|
||||||
|
// on a cluster should not wait out the cache to see it here.
|
||||||
|
func (a *App) RefreshDXpeditions() {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
a.dxped.Invalidate()
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ var deniedCallHashes = map[string]struct{}{
|
|||||||
"46fb61e71afb40627cb6493a6a59c9d4d0231ee3cad1a2bf3fcc4cdfce36fabc": {},
|
"46fb61e71afb40627cb6493a6a59c9d4d0231ee3cad1a2bf3fcc4cdfce36fabc": {},
|
||||||
"d1ae6212ec057f9d5c6f379fb57acedac7c94142c9d49667dc04b2016d03d1b6": {},
|
"d1ae6212ec057f9d5c6f379fb57acedac7c94142c9d49667dc04b2016d03d1b6": {},
|
||||||
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
|
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
|
||||||
|
"ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {},
|
||||||
|
"9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {},
|
||||||
|
"94ee059335e587e501cc4bf90613e0814f00a7b08bc7c648fd865a2af6a22cc2": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The voice keyer, through an Icom's network link — the Icom face of what
|
||||||
|
// app_tci_dvk.go does for a SunSDR: selecting the radio as the "To radio"
|
||||||
|
// output hands messages to the 50003 audio session instead of a sound card.
|
||||||
|
// The same PTT before and after, the same gain, the same files.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/audio"
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// icomTXPlayer hands one message to the radio. Fetched on the CAT goroutine,
|
||||||
|
// played off it — a ten-second message must not freeze frequency, mode and
|
||||||
|
// PTT handling for ten seconds (see tciTXPlayer, which set the pattern).
|
||||||
|
func (a *App) icomTXPlayer(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("CAT not initialized")
|
||||||
|
}
|
||||||
|
type txPlayer interface {
|
||||||
|
PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
|
||||||
|
}
|
||||||
|
var player txPlayer
|
||||||
|
err := a.cat.IcomDo(func(ic cat.IcomController) error {
|
||||||
|
p, ok := ic.(txPlayer)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("this radio cannot take transmit audio over its CAT link")
|
||||||
|
}
|
||||||
|
player = p
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return player.PlayTXAudio(pcm, rate, ch, bits, stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// installIcomTXPlayer offers the network Icom as an audio output, or withdraws
|
||||||
|
// it. Withdrawing matters as much as offering — see installTCITXPlayer.
|
||||||
|
func (a *App) installIcomTXPlayer(on bool) {
|
||||||
|
if !on {
|
||||||
|
return // the reloadCAT preamble already cleared the network player
|
||||||
|
}
|
||||||
|
audio.SetNetworkPlayer(a.icomTXPlayer)
|
||||||
|
applog.Printf("icom net: the radio is available as an audio output — the voice keyer can play to it without a cable")
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Elecraft K3/K4 panel bindings.
|
||||||
|
//
|
||||||
|
// The controls the operator asked for and nothing else: power, volume, the
|
||||||
|
// S-meter and SWR, MOX, and an ATU tune. A K3 exposes dozens of commands, but a
|
||||||
|
// panel earns its place by covering what is reached for during a QSO — and each
|
||||||
|
// one added without a radio to test it against is a control that may or may not
|
||||||
|
// do what its label says. See internal/cat/kenwood_panel.go.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetKenwoodState returns the K3/K4 panel snapshot. Zero value when the active
|
||||||
|
// CAT backend is something else, which is how the UI knows to hide the panel.
|
||||||
|
func (a *App) GetKenwoodState() cat.KenwoodTXState {
|
||||||
|
if a.cat == nil {
|
||||||
|
return cat.KenwoodTXState{}
|
||||||
|
}
|
||||||
|
st, _ := a.cat.KenwoodState()
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKenwoodPanelMode sets the operating mode from the panel's mode row —
|
||||||
|
// CW / USB / LSB / DATA (soundcard, DT0) / RTTY (FSK D, DT2).
|
||||||
|
func (a *App) SetKenwoodPanelMode(mode string) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPanelMode(mode) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKenwoodRITOffset sets the RIT/XIT offset to an absolute value in Hz.
|
||||||
|
func (a *App) SetKenwoodRITOffset(hz int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodRITOffset(hz) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodPower(w int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPower(w) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodAFGain(p int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodAFGain(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodRFGain(p int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodRFGain(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodMicGain(p int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodMicGain(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodSquelch(p int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodSquelch(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodPreamp(on bool) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPreamp(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodAtt(on bool) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodAtt(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodNB(on bool) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodNB(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodNR(on bool) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodNR(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodAGC(name string) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodAGC(name) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodFilter(hz int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodFilter(hz) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodAntenna(n int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodAntenna(n) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodRIT(on bool) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodRIT(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodXIT(on bool) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodXIT(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// NudgeKenwoodRIT moves the RIT/XIT offset by delta Hz.
|
||||||
|
func (a *App) NudgeKenwoodRIT(delta int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.NudgeKenwoodRIT(delta) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearKenwoodRIT zeroes the RIT and XIT offsets together, which is what the
|
||||||
|
// radio's own RC does and what an operator means by "clear it".
|
||||||
|
func (a *App) ClearKenwoodRIT() error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.ClearKenwoodRIT() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKenwoodTX is the panel's MOX.
|
||||||
|
func (a *App) SetKenwoodTX(on bool) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodTX(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleKenwoodATU puts the tuner in line or bypasses it (the HOLD of the same
|
||||||
|
// front-panel switch the tune uses).
|
||||||
|
func (a *App) ToggleKenwoodATU() error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.ToggleKenwoodATU() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) TuneKenwoodATU() error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.TuneKenwoodATU() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshKenwood re-reads the settings on the next poll — for when a knob was
|
||||||
|
// turned on the radio itself.
|
||||||
|
func (a *App) RefreshKenwood() error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.RefreshKenwood() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) kenwoodPanelDo(fn func(cat.KenwoodPanelController) error) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("CAT not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.KenwoodPanelDo(fn)
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/psu"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Bench power supply (Modbus RTU) ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A programmable supply feeding the shack, switched on and off from OpsLog so
|
||||||
|
// the station comes up and goes down with the logbook rather than by reaching
|
||||||
|
// behind the desk.
|
||||||
|
//
|
||||||
|
// OpsLog READS the supply's measurements and its set points, and WRITES exactly
|
||||||
|
// one thing: the output on/off. The register map has the voltage and current
|
||||||
|
// set points and the three protection trip levels as writable too, and none of
|
||||||
|
// them belong to a logbook — a wrong value there is 30 V where a radio expected
|
||||||
|
// 13.8. See internal/psu for the map and where it comes from.
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyPSUEnabled = "psu.enabled"
|
||||||
|
keyPSUPort = "psu.com_port"
|
||||||
|
keyPSUBaud = "psu.baud"
|
||||||
|
keyPSUAddress = "psu.address" // Modbus slave address, 1…15 on this family
|
||||||
|
)
|
||||||
|
|
||||||
|
// PSUSettings is the JSON shape for the Hardware → Power supply panel.
|
||||||
|
type PSUSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
ComPort string `json:"com_port"`
|
||||||
|
Baud int `json:"baud"` // 9600 from the factory
|
||||||
|
Address int `json:"address"` // 1 from the factory
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPSUSettings returns the persisted supply config.
|
||||||
|
func (a *App) GetPSUSettings() (PSUSettings, error) {
|
||||||
|
out := PSUSettings{Baud: 9600, Address: 1}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
m, err := a.settings.GetMany(a.ctx, keyPSUEnabled, keyPSUPort, keyPSUBaud, keyPSUAddress)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Enabled = m[keyPSUEnabled] == "1"
|
||||||
|
out.ComPort = m[keyPSUPort]
|
||||||
|
if v, e := strconv.Atoi(m[keyPSUBaud]); e == nil && v > 0 {
|
||||||
|
out.Baud = v
|
||||||
|
}
|
||||||
|
if v, e := strconv.Atoi(m[keyPSUAddress]); e == nil && v >= 1 && v <= 250 {
|
||||||
|
out.Address = v
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SavePSUSettings persists the config and (re)starts or stops the client.
|
||||||
|
func (a *App) SavePSUSettings(s PSUSettings) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if s.Baud <= 0 {
|
||||||
|
s.Baud = 9600
|
||||||
|
}
|
||||||
|
// The manual gives 1…15 for the address field and 1…250 for the address
|
||||||
|
// SETTING register. Clamped to the wider range and defaulted to 1: an
|
||||||
|
// address of 0 is the Modbus broadcast, which never answers, so accepting it
|
||||||
|
// would give a supply that is present and permanently "not responding".
|
||||||
|
if s.Address < 1 || s.Address > 250 {
|
||||||
|
s.Address = 1
|
||||||
|
}
|
||||||
|
for k, v := range map[string]string{
|
||||||
|
keyPSUEnabled: boolStr(s.Enabled),
|
||||||
|
keyPSUPort: strings.TrimSpace(s.ComPort),
|
||||||
|
keyPSUBaud: strconv.Itoa(s.Baud),
|
||||||
|
keyPSUAddress: strconv.Itoa(s.Address),
|
||||||
|
} {
|
||||||
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.restartAsync("psu", a.startPSU)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startPSU stops any running client and starts a fresh one if the supply is
|
||||||
|
// enabled and has a port. Safe to call repeatedly (startup, settings save,
|
||||||
|
// profile switch).
|
||||||
|
func (a *App) startPSU() {
|
||||||
|
if a.psu != nil {
|
||||||
|
go a.psu.Stop()
|
||||||
|
a.psu = nil
|
||||||
|
}
|
||||||
|
s, err := a.GetPSUSettings()
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("psu: not started — settings unavailable: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.ComPort) == "" {
|
||||||
|
applog.Printf("psu: not started — no serial port configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("psu: starting on %s @ %d baud, Modbus address %d", s.ComPort, s.Baud, s.Address)
|
||||||
|
a.psu = psu.New(psu.Config{ComPort: s.ComPort, Baud: s.Baud, Address: byte(s.Address)})
|
||||||
|
_ = a.psu.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPSUStatus returns the supply's last polled state for the UI.
|
||||||
|
func (a *App) GetPSUStatus() psu.Status {
|
||||||
|
if a.psu == nil {
|
||||||
|
return psu.Status{}
|
||||||
|
}
|
||||||
|
return a.psu.GetStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPSUOutput switches the supply's output. The only write OpsLog makes to it.
|
||||||
|
func (a *App) SetPSUOutput(on bool) error {
|
||||||
|
if a.psu == nil {
|
||||||
|
return fmt.Errorf("the power supply is not enabled in Settings")
|
||||||
|
}
|
||||||
|
return a.psu.SetOutput(on)
|
||||||
|
}
|
||||||
+188
-3
@@ -31,7 +31,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
keyQSLEmailSubject = "qsl.email_subject"
|
keyQSLEmailSubject = "qsl.email_subject"
|
||||||
keyQSLEmailBody = "qsl.email_body"
|
keyQSLEmailBody = "qsl.email_body"
|
||||||
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
|
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
|
||||||
keyQSLDefaultMsg = "qsl.default_message" // fallback QSL message printed on the card when the QSO's own QSLMSG is empty
|
keyQSLDefaultMsg = "qsl.default_message" // fallback QSL message printed on the card when the QSO's own QSLMSG is empty
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,9 +45,20 @@ const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
|
|||||||
// (please send one). Independent of ADIF qsl_rcvd, like the sent field.
|
// (please send one). Independent of ADIF qsl_rcvd, like the sent field.
|
||||||
const appQSLCardRcvdField = "APP_OPSLOG_QSL_RCVD"
|
const appQSLCardRcvdField = "APP_OPSLOG_QSL_RCVD"
|
||||||
|
|
||||||
|
// qslCredit closes the default QSL e-mail: who made the card, and where the
|
||||||
|
// recipient can get the same program.
|
||||||
|
//
|
||||||
|
// A DEFAULT, not a signature. It lives in the body template like every other
|
||||||
|
// line, so an operator who does not want it deletes it once and it is gone —
|
||||||
|
// and one who has already written their own body never sees it appear, because
|
||||||
|
// a stored template is returned verbatim and the default is only the fallback.
|
||||||
|
// Appending it at send time instead would have made it unremovable, which is
|
||||||
|
// not a thing to do to someone's outgoing mail.
|
||||||
|
const qslCredit = "--\nDesigned & sent by OpsLog — " + releasesPageURL
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
|
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
|
||||||
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
|
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}\n\n" + qslCredit
|
||||||
)
|
)
|
||||||
|
|
||||||
// qslDir is the root of all designer artifacts: templates/<id>/ and outbox/.
|
// qslDir is the root of all designer artifacts: templates/<id>/ and outbox/.
|
||||||
@@ -705,7 +716,10 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
|
|||||||
}
|
}
|
||||||
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
|
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
|
||||||
if q.FreqHz != nil {
|
if q.FreqHz != nil {
|
||||||
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) + " MHz"
|
// Megahertz, without the unit. The column is labelled FREQ and the box is
|
||||||
|
// narrow — "10.136 MHz" ran into the MODE column beside it, and a card that
|
||||||
|
// says FREQ 10.136 has never confused anybody.
|
||||||
|
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64)
|
||||||
}
|
}
|
||||||
return vars, a.qslCountryInfo(), nil
|
return vars, a.qslCountryInfo(), nil
|
||||||
}
|
}
|
||||||
@@ -738,13 +752,184 @@ func (a *App) qslCountryInfo() qslcard.CountryInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sampleQSO is the canned contact used by the editor's live preview.
|
// sampleQSO is the canned contact used by the editor's live preview.
|
||||||
|
//
|
||||||
|
// Every field the QSO box can show must be FILLED here, and filled with a value
|
||||||
|
// of realistic width. The preview is the only place an operator can see whether
|
||||||
|
// a column fits before printing a card — a blank one silently promises room it
|
||||||
|
// will not have. The frequency is the widest of them, hence a real 20 m SSB one
|
||||||
|
// rather than a round number.
|
||||||
func sampleQSO() qso.QSO {
|
func sampleQSO() qso.QSO {
|
||||||
|
hz := int64(14285000)
|
||||||
return qso.QSO{
|
return qso.QSO{
|
||||||
Callsign: "DL1ABC",
|
Callsign: "DL1ABC",
|
||||||
QSODate: time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC),
|
QSODate: time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC),
|
||||||
Band: "20m",
|
Band: "20m",
|
||||||
Mode: "SSB",
|
Mode: "SSB",
|
||||||
|
Submode: "USB",
|
||||||
|
FreqHz: &hz,
|
||||||
RSTSent: "59",
|
RSTSent: "59",
|
||||||
QSLMsg: "TNX FB QSO — 73!",
|
QSLMsg: "TNX FB QSO — 73!",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QSLForeignTemplate is one card design belonging to ANOTHER profile, offered
|
||||||
|
// for copying into the active one.
|
||||||
|
type QSLForeignTemplate struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ProfileID int64 `json:"profile_id"`
|
||||||
|
ProfileName string `json:"profile_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLListOtherProfileTemplates lists the designs the ACTIVE profile cannot see.
|
||||||
|
//
|
||||||
|
// A second profile is usually the same operator with a different rig or a
|
||||||
|
// different locator — same callsign, same cards. Redrawing a design because the
|
||||||
|
// antenna changed is work nobody should have to do, and the designs a profile
|
||||||
|
// already sees (its own, and the shared ones) are deliberately left out: they
|
||||||
|
// are not something to copy.
|
||||||
|
func (a *App) QSLListOtherProfileTemplates() ([]QSLForeignTemplate, error) {
|
||||||
|
if a.qslTemplates == nil || a.profiles == nil {
|
||||||
|
return nil, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
active := int64(-1)
|
||||||
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
|
active = p.ID
|
||||||
|
}
|
||||||
|
names := map[int64]string{}
|
||||||
|
if list, err := a.ListProfiles(); err == nil {
|
||||||
|
for _, p := range list {
|
||||||
|
names[p.ID] = p.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all, err := a.qslTemplates.List(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []QSLForeignTemplate{}
|
||||||
|
for _, r := range all {
|
||||||
|
// Shared designs (no profile) already appear in every profile's list.
|
||||||
|
if r.ProfileID == nil || *r.ProfileID == active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, QSLForeignTemplate{
|
||||||
|
ID: r.ID, Name: r.Name, ProfileID: *r.ProfileID,
|
||||||
|
ProfileName: firstNonEmptyStr(names[*r.ProfileID], fmt.Sprintf("profile %d", *r.ProfileID)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLCopyTemplateToActiveProfile duplicates another profile's design into the
|
||||||
|
// active one and returns the new id.
|
||||||
|
//
|
||||||
|
// A COPY, not a move or a share. The original profile keeps its design
|
||||||
|
// untouched, and the copy is free to diverge — a second profile usually exists
|
||||||
|
// because something differs, and that something often ends up on the card.
|
||||||
|
func (a *App) QSLCopyTemplateToActiveProfile(id int64) (int64, error) {
|
||||||
|
if a.qslTemplates == nil {
|
||||||
|
return 0, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
src, err := a.qslTemplates.Get(a.ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// The stored document references photos by NAME, relative to the template's
|
||||||
|
// own asset folder — so copying the JSON alone would point the new design at
|
||||||
|
// files that are not in its folder, and the save would fail validation.
|
||||||
|
//
|
||||||
|
// The files are copied too, giving the duplicate its own folder. Sharing the
|
||||||
|
// source's would mean deleting one design silently emptied the other.
|
||||||
|
rec := qslcard.Record{Name: src.Name + " (copy)", JSON: src.JSON}
|
||||||
|
if p, e := a.profiles.Active(a.ctx); e == nil {
|
||||||
|
rec.ProfileID = &p.ID
|
||||||
|
}
|
||||||
|
// Saved first: a new template needs its id before it can own a folder.
|
||||||
|
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
srcDir := qslcard.TemplateDir(a.qslDir(), id)
|
||||||
|
dstDir := qslcard.TemplateDir(a.qslDir(), rec.ID)
|
||||||
|
// A design with no pictures at all has no folder, and that is not a failure.
|
||||||
|
// Anything else IS: this used to be written as "ignore os.IsNotExist", which
|
||||||
|
// also swallowed every ENOENT raised while copying — so a failed copy looked
|
||||||
|
// like a design that had nothing to copy, and the operator got "hero photo
|
||||||
|
// not found" with no clue why.
|
||||||
|
if _, statErr := os.Stat(srcDir); statErr == nil {
|
||||||
|
if err := copyDirContents(srcDir, dstDir); err != nil {
|
||||||
|
// Roll back rather than leave a design whose pictures are missing.
|
||||||
|
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
|
||||||
|
_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
|
||||||
|
return 0, fmt.Errorf("copy template assets: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every failure from here rolls back BOTH halves: deleting only the row would
|
||||||
|
// leave the copied pictures on disk under an id nothing refers to.
|
||||||
|
rollback := func() {
|
||||||
|
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
|
||||||
|
_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
|
||||||
|
}
|
||||||
|
t, err := qslcard.Parse([]byte(rec.JSON))
|
||||||
|
if err != nil {
|
||||||
|
rollback()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dstDir)); err != nil {
|
||||||
|
rollback()
|
||||||
|
return 0, fmt.Errorf("copied design is incomplete: %w", err)
|
||||||
|
}
|
||||||
|
applog.Printf("qsl: copied template %q (id %d) into the active profile as %q (id %d)",
|
||||||
|
src.Name, id, rec.Name, rec.ID)
|
||||||
|
return rec.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyQSLTemplatesToProfile duplicates one profile's QSL designs into another,
|
||||||
|
// PICTURES INCLUDED.
|
||||||
|
//
|
||||||
|
// This was an INSERT … SELECT, which cloned the rows and nothing else. A design
|
||||||
|
// references its photos by name, relative to its own asset folder, so every
|
||||||
|
// duplicated design pointed at a folder that did not exist: no thumbnail in the
|
||||||
|
// designer, and nothing to print. The rows looked right, which is why it went
|
||||||
|
// unnoticed — the damage is entirely on disk.
|
||||||
|
//
|
||||||
|
// Row by row rather than in one statement, because each new design needs its id
|
||||||
|
// before it can own a folder. A design whose files cannot be copied is dropped
|
||||||
|
// rather than kept empty: an operator who sees the design listed will believe
|
||||||
|
// it works.
|
||||||
|
func (a *App) copyQSLTemplatesToProfile(fromProfile, toProfile int64) error {
|
||||||
|
if a.qslTemplates == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
all, err := a.qslTemplates.List(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, src := range all {
|
||||||
|
if src.ProfileID == nil || *src.ProfileID != fromProfile {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rec := qslcard.Record{Name: src.Name, JSON: src.JSON, IsDefault: src.IsDefault}
|
||||||
|
to := toProfile
|
||||||
|
rec.ProfileID = &to
|
||||||
|
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
srcDir := qslcard.TemplateDir(a.qslDir(), src.ID)
|
||||||
|
if _, statErr := os.Stat(srcDir); statErr != nil {
|
||||||
|
continue // a design with no pictures at all — nothing to carry over
|
||||||
|
}
|
||||||
|
if err := copyDirContents(srcDir, qslcard.TemplateDir(a.qslDir(), rec.ID)); err != nil {
|
||||||
|
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
|
||||||
|
_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
|
||||||
|
return fmt.Errorf("copy assets of %q: %w", src.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyStr(a, b string) string {
|
||||||
|
if strings.TrimSpace(a) != "" {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|||||||
+246
@@ -0,0 +1,246 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Several radios, and the one that is on the air.
|
||||||
|
//
|
||||||
|
// Until now the answer to "I have two rigs" was "make two profiles", which is a
|
||||||
|
// heavy instrument for a light question: a profile carries the whole station —
|
||||||
|
// logbook, callsign, awards, cluster, macros — and switching one to change
|
||||||
|
// which radio is connected takes all of that with it, including a logbook
|
||||||
|
// change if the profiles point at different databases.
|
||||||
|
//
|
||||||
|
// So radios are a LIST, the way amplifiers already are, and switching is one
|
||||||
|
// click on the CAT chip in the status bar. Nothing else about the station
|
||||||
|
// moves.
|
||||||
|
//
|
||||||
|
// The storage deliberately keeps the ACTIVE radio in the settings keys the rest
|
||||||
|
// of OpsLog already reads (cat.backend, cat.icom_port, …). Switching writes the
|
||||||
|
// chosen entry into those keys and reloads the link, so every consumer — the
|
||||||
|
// consoles, the CAT sharing, the band-follow, the panels — sees exactly what it
|
||||||
|
// saw before and needed no change at all. The list is a second store beside it,
|
||||||
|
// not a replacement for it.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// keyRadiosList holds the saved radios as JSON.
|
||||||
|
keyRadiosList = "cat.radios.json"
|
||||||
|
// keyRadioActive is the id of the one currently connected.
|
||||||
|
keyRadioActive = "cat.radios.active"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RadioConfig is one saved radio: a name and the CAT settings that reach it.
|
||||||
|
type RadioConfig struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Settings is the whole CAT configuration for this radio — the same shape
|
||||||
|
// the CAT panel has always edited, so a saved radio is exactly "what the
|
||||||
|
// settings said the day it was saved".
|
||||||
|
Settings CATSettings `json:"settings"`
|
||||||
|
// MyRig is what goes into MY_RIG on a QSO made with this radio.
|
||||||
|
//
|
||||||
|
// It belongs here rather than in Operating conditions once there is more
|
||||||
|
// than one rig: the operating conditions describe a PLAN — "on 20 m I use
|
||||||
|
// the beam and the 7300" — while this is the fact of which radio is keying.
|
||||||
|
// Left empty, nothing changes: the old chain (band default, then the
|
||||||
|
// profile's rig) answers exactly as it did.
|
||||||
|
MyRig string `json:"my_rig"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// radioLabel is what the status bar shows when the operator never named one.
|
||||||
|
func radioLabel(c RadioConfig, i int) string {
|
||||||
|
if n := strings.TrimSpace(c.Name); n != "" {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
if b := strings.TrimSpace(c.Settings.Backend); b != "" {
|
||||||
|
return strings.ToUpper(b)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Radio %d", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRadios returns the saved radios.
|
||||||
|
//
|
||||||
|
// When nothing was ever saved, the CURRENT settings are presented as the single
|
||||||
|
// entry — so an operator who has been running one rig for a year opens the list
|
||||||
|
// and finds it there, rather than an empty box suggesting their configuration
|
||||||
|
// has been lost. It is persisted on the next save, not here: reading a list
|
||||||
|
// should not write one.
|
||||||
|
func (a *App) GetRadios() ([]RadioConfig, error) {
|
||||||
|
if a.settings == nil {
|
||||||
|
return nil, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
raw := a.settingOr(keyRadiosList, "")
|
||||||
|
if strings.TrimSpace(raw) != "" {
|
||||||
|
var list []RadioConfig
|
||||||
|
if err := json.Unmarshal([]byte(raw), &list); err == nil && len(list) > 0 {
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cur, err := a.GetCATSettings()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []RadioConfig{{ID: "radio-1", Name: "", Settings: cur}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRadios stores the list, giving an id to anything new.
|
||||||
|
func (a *App) SaveRadios(list []RadioConfig) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
for i := range list {
|
||||||
|
if strings.TrimSpace(list[i].ID) == "" {
|
||||||
|
list[i].ID = fmt.Sprintf("radio-%d-%d", time.Now().Unix(), i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(list)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.settings.Set(a.ctx, keyRadiosList, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveRadioID is the id of the radio currently on the air, or the first one
|
||||||
|
// when nothing was ever chosen.
|
||||||
|
func (a *App) ActiveRadioID() string {
|
||||||
|
id := strings.TrimSpace(a.settingOr(keyRadioActive, ""))
|
||||||
|
list, err := a.GetRadios()
|
||||||
|
if err != nil || len(list) == 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
for _, r := range list {
|
||||||
|
if r.ID == id {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The stored id names a radio that has since been deleted. The first one is
|
||||||
|
// a better answer than an empty selection: the CAT link is up, and it is up
|
||||||
|
// on SOMETHING.
|
||||||
|
return list[0].ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// RadioListEntry is what the status-bar menu needs: enough to draw a row.
|
||||||
|
//
|
||||||
|
// Name is what the OPERATOR typed, empty when they typed nothing; Label is what
|
||||||
|
// to draw when there is no better idea. The two are separate because the caller
|
||||||
|
// has a better idea than we do: the status bar knows what the radio calls
|
||||||
|
// itself over CAT, and "FTDX10" beats "Radio 2" — but only where the operator
|
||||||
|
// has not given it a name of their own, which beats both.
|
||||||
|
type RadioListEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRadios is the status bar's view of the list.
|
||||||
|
func (a *App) ListRadios() []RadioListEntry {
|
||||||
|
list, err := a.GetRadios()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
active := a.ActiveRadioID()
|
||||||
|
out := make([]RadioListEntry, 0, len(list))
|
||||||
|
for i, r := range list {
|
||||||
|
out = append(out, RadioListEntry{
|
||||||
|
ID: r.ID, Name: strings.TrimSpace(r.Name), Label: radioLabel(r, i),
|
||||||
|
Backend: r.Settings.Backend, Active: r.ID == active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveRadio connects the given radio and leaves the rest of the station
|
||||||
|
// alone.
|
||||||
|
//
|
||||||
|
// The chosen entry's settings become THE CAT settings — SaveCATSettings writes
|
||||||
|
// them and restarts the link — so switching rigs is the same operation as
|
||||||
|
// editing the CAT panel and pressing Save, which is a path that already works
|
||||||
|
// everywhere it needs to.
|
||||||
|
func (a *App) SetActiveRadio(id string) error {
|
||||||
|
list, err := a.GetRadios()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
for i, r := range list {
|
||||||
|
if r.ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Persisted BEFORE the link is rebuilt: reloadCAT can take a moment on a
|
||||||
|
// radio that is switched off, and an operator who closes OpsLog during
|
||||||
|
// that moment should still come back to the rig they chose.
|
||||||
|
a.setSetting(keyRadioActive, id)
|
||||||
|
// The list is written back as well when it was only ever implicit, so
|
||||||
|
// the first switch is also what makes the list real.
|
||||||
|
if strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
|
||||||
|
_ = a.SaveRadios(list)
|
||||||
|
}
|
||||||
|
applog.Printf("cat: switching to %q (%s)", radioLabel(r, i), r.Settings.Backend)
|
||||||
|
return a.SaveCATSettings(r.Settings)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no radio with id %q", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncActiveRadio writes the settings just saved into the active entry of the
|
||||||
|
// list, so the list and the live configuration never disagree.
|
||||||
|
//
|
||||||
|
// Only when a list actually exists: an operator with one radio who has never
|
||||||
|
// opened the list has nothing to keep in step, and writing one here would
|
||||||
|
// create a list as a side effect of saving the CAT panel.
|
||||||
|
func (a *App) syncActiveRadio(s CATSettings) {
|
||||||
|
if a.settings == nil || strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := a.GetRadios()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := a.ActiveRadioID()
|
||||||
|
for i := range list {
|
||||||
|
if list[i].ID == id {
|
||||||
|
list[i].Settings = s
|
||||||
|
_ = a.SaveRadios(list)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeRadioMyRig is the MY_RIG of the radio currently connected, or "".
|
||||||
|
//
|
||||||
|
// Consulted BEFORE the per-band default, and deliberately: the band default is
|
||||||
|
// what the operator planned to use on that band, this is which radio is
|
||||||
|
// actually on the air. When they disagree — a second rig borrowed for one
|
||||||
|
// evening on 20 m — the one that is transmitting is the true answer.
|
||||||
|
func (a *App) activeRadioMyRig() string {
|
||||||
|
if a.settings == nil || strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
|
||||||
|
return "" // no list was ever made: nothing to say, nothing changes
|
||||||
|
}
|
||||||
|
list, err := a.GetRadios()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id := a.ActiveRadioID()
|
||||||
|
for _, r := range list {
|
||||||
|
if r.ID == id {
|
||||||
|
return strings.TrimSpace(r.MyRig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveRadioMyRig exposes the connected radio's MY_RIG to the frontend, which
|
||||||
|
// pre-fills the entry form's My-station fields on every band change. Without
|
||||||
|
// this the per-band default landed in the field FIRST, and the log-time
|
||||||
|
// priority (the radio that is keying beats the radio that was planned) never
|
||||||
|
// ran — the field was no longer empty by the time the backend looked.
|
||||||
|
func (a *App) ActiveRadioMyRig() string {
|
||||||
|
return a.activeRadioMyRig()
|
||||||
|
}
|
||||||
+980
@@ -0,0 +1,980 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Satellites — the wiring around internal/sat.
|
||||||
|
//
|
||||||
|
// The package knows orbits and frequency plans; this file is what the station
|
||||||
|
// knows: where the antenna is, which birds the operator cares about, and where
|
||||||
|
// the elements are kept. Nothing here talks to a radio or a rotator yet — that
|
||||||
|
// is the next layer, and it is deliberately built on top of GetSatelliteTuning
|
||||||
|
// rather than beside it, so what the operator reads on screen and what gets
|
||||||
|
// sent to the rig can never disagree.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/sat"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keySatFavorites = "sat.favorites" // comma-separated satellite names
|
||||||
|
keySatMinEl = "sat.min_el" // degrees; passes lower than this are not listed
|
||||||
|
keySatWindowH = "sat.window_h" // hours of pass predictions
|
||||||
|
keySatAutoTLE = "sat.auto_tle" // fetch elements at startup when the set is stale
|
||||||
|
keySatGrid = "sat.grid" // locator override ("" = the station's own)
|
||||||
|
keySatAltM = "sat.alt_m" // antenna height above sea level, metres
|
||||||
|
|
||||||
|
// The az/el rotator. Its own settings rather than the HF rotator's: a
|
||||||
|
// satellite station's elevation rotator is a different machine on a
|
||||||
|
// different port, and an operator who has both must not have to choose.
|
||||||
|
keySatRotOn = "sat.rot_enabled"
|
||||||
|
// Which program drives the mast: OpsLog itself over EasyComm, or PstRotator,
|
||||||
|
// which many stations already run in front of their controller. Its own port
|
||||||
|
// key because it is a different program on a different port from an EasyComm
|
||||||
|
// controller, and an operator who tries both must not lose the first setting
|
||||||
|
// to the second.
|
||||||
|
keySatRotType = "sat.rot_type" // "easycomm" | "pstrotator"
|
||||||
|
keySatRotPstPort = "sat.rot_pst_port" // PstRotator's UDP command port
|
||||||
|
keySatRotTransport = "sat.rot_transport" // "serial" | "tcp"
|
||||||
|
keySatRotHost = "sat.rot_host"
|
||||||
|
keySatRotPort = "sat.rot_port"
|
||||||
|
keySatRotCOM = "sat.rot_com"
|
||||||
|
keySatRotBaud = "sat.rot_baud"
|
||||||
|
keySatRotMaxAz = "sat.rot_max_az" // 360 or 450
|
||||||
|
keySatRotMinEl = "sat.rot_min_el" // don't drive the rotator below this elevation
|
||||||
|
keySatRotStep = "sat.rot_step" // degrees of change worth a command
|
||||||
|
keySatRotPark = "sat.rot_park" // park at az 0 / el 0 when tracking stops
|
||||||
|
)
|
||||||
|
|
||||||
|
// customTLEName holds elements the operator pasted in by hand.
|
||||||
|
//
|
||||||
|
// Kept apart from the feed cache because the cache is REPLACED wholesale on
|
||||||
|
// every refresh: a freshly launched satellite, whose elements arrive on a
|
||||||
|
// mailing list days before any feed carries it, would be wiped by the first
|
||||||
|
// automatic update — which is precisely the week everybody wants to hear it.
|
||||||
|
const customTLEName = "satellites.custom.tle"
|
||||||
|
|
||||||
|
// SatSettings is the station's side of satellite work.
|
||||||
|
type SatSettings struct {
|
||||||
|
Favorites []string `json:"favorites"`
|
||||||
|
MinEl int `json:"min_el"`
|
||||||
|
WindowH int `json:"window_h"`
|
||||||
|
AutoTLE bool `json:"auto_tle"`
|
||||||
|
Grid string `json:"grid"`
|
||||||
|
AltM int `json:"alt_m"`
|
||||||
|
|
||||||
|
// The az/el rotator.
|
||||||
|
RotOn bool `json:"rot_on"`
|
||||||
|
RotType string `json:"rot_type"`
|
||||||
|
RotPstPort int `json:"rot_pst_port"`
|
||||||
|
RotTransport string `json:"rot_transport"`
|
||||||
|
RotHost string `json:"rot_host"`
|
||||||
|
RotPort int `json:"rot_port"`
|
||||||
|
RotCOM string `json:"rot_com"`
|
||||||
|
RotBaud int `json:"rot_baud"`
|
||||||
|
RotMaxAz int `json:"rot_max_az"`
|
||||||
|
RotMinEl int `json:"rot_min_el"`
|
||||||
|
RotStep int `json:"rot_step"`
|
||||||
|
RotPark bool `json:"rot_park"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatTransponder is one path through a satellite, as the UI needs it.
|
||||||
|
type SatTransponder struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
DownLo int64 `json:"down_lo"`
|
||||||
|
DownHi int64 `json:"down_hi"`
|
||||||
|
UpLo int64 `json:"up_lo"`
|
||||||
|
UpHi int64 `json:"up_hi"`
|
||||||
|
Inverting bool `json:"inverting"`
|
||||||
|
CTCSS float64 `json:"ctcss"`
|
||||||
|
Linear bool `json:"linear"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatBird is a satellite as the operator sees it: the frequency plan joined to
|
||||||
|
// whatever elements we hold for it.
|
||||||
|
type SatBird struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
NORAD int `json:"norad"`
|
||||||
|
Geostationary bool `json:"geostationary"`
|
||||||
|
Favorite bool `json:"favorite"`
|
||||||
|
HasElements bool `json:"has_elements"`
|
||||||
|
ElementName string `json:"element_name"` // the feed's spelling, when it differs
|
||||||
|
EpochAgeH float64 `json:"epoch_age_h"`
|
||||||
|
Transponders []SatTransponder `json:"transponders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatTLEInfo describes the element set the station is working from.
|
||||||
|
type SatTLEInfo struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
FetchedAt time.Time `json:"fetched_at"`
|
||||||
|
AgeH float64 `json:"age_h"`
|
||||||
|
Stale bool `json:"stale"`
|
||||||
|
Custom int `json:"custom"` // hand-entered satellites among the count
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatTuning is where to listen and where to transmit, right now.
|
||||||
|
//
|
||||||
|
// Both the nominal and the corrected pair are returned on purpose: the nominal
|
||||||
|
// is what goes in the log (see the ADIF note on SAT_NAME) and the corrected is
|
||||||
|
// what goes to the radio. An operator staring at a display that shows only one
|
||||||
|
// of them cannot tell a Doppler correction from a mistuned transponder.
|
||||||
|
type SatTuning struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Transponder string `json:"transponder"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
NominalDown int64 `json:"nominal_down"`
|
||||||
|
NominalUp int64 `json:"nominal_up"`
|
||||||
|
DownHz int64 `json:"down_hz"`
|
||||||
|
UpHz int64 `json:"up_hz"`
|
||||||
|
CTCSS float64 `json:"ctcss"`
|
||||||
|
Inverting bool `json:"inverting"`
|
||||||
|
|
||||||
|
Az float64 `json:"az"`
|
||||||
|
El float64 `json:"el"`
|
||||||
|
RangeKm float64 `json:"range_km"`
|
||||||
|
RangeRate float64 `json:"range_rate"`
|
||||||
|
Visible bool `json:"visible"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
|
||||||
|
// Where the satellite is over the earth. Carried with the tuning because
|
||||||
|
// they are read together and change together — the panel would otherwise ask
|
||||||
|
// twice a second for two halves of one instant.
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
AltKm float64 `json:"alt_km"`
|
||||||
|
Footprint float64 `json:"footprint_km"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatPassInfo is the pass in progress, or the next one.
|
||||||
|
//
|
||||||
|
// Separate from the tuning and polled far more slowly: predicting a pass steps
|
||||||
|
// the orbit thirty seconds at a time across hours, which is not something to do
|
||||||
|
// once a second for a countdown a browser can run itself from two timestamps.
|
||||||
|
type SatPassInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
HasPass bool `json:"has_pass"`
|
||||||
|
// InPass distinguishes "it is up now" from "it rises at". The pass in
|
||||||
|
// progress is reported whatever its maximum elevation: an operator watching
|
||||||
|
// a satellite go over does not want it hidden because it fell below the
|
||||||
|
// threshold that filters the TABLE of what is worth waiting for.
|
||||||
|
InPass bool `json:"in_pass"`
|
||||||
|
AOS time.Time `json:"aos"`
|
||||||
|
LOS time.Time `json:"los"`
|
||||||
|
AOSAz float64 `json:"aos_az"`
|
||||||
|
LOSAz float64 `json:"los_az"`
|
||||||
|
MaxEl float64 `json:"max_el"`
|
||||||
|
MaxElAz float64 `json:"max_el_az"`
|
||||||
|
MaxElAt time.Time `json:"max_el_at"`
|
||||||
|
Duration float64 `json:"duration_s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// startSatellites loads what is already on disk and, only if asked, goes to the
|
||||||
|
// network.
|
||||||
|
//
|
||||||
|
// Cache first and synchronously: it is one file and a few hundred parses, and
|
||||||
|
// it means the satellite tab is populated the instant it is opened, on a shack
|
||||||
|
// PC with no internet as much as on one with. The fetch is the slow, optional
|
||||||
|
// half and never blocks a launch.
|
||||||
|
func (a *App) startSatellites() {
|
||||||
|
dir := a.dataDir
|
||||||
|
birds, err := sat.LoadBirds(dir)
|
||||||
|
if err != nil {
|
||||||
|
// LoadBirds always returns a usable list; the error says the operator's
|
||||||
|
// own file was refused, which they need to be told about.
|
||||||
|
applog.Printf("sat: %v", err)
|
||||||
|
}
|
||||||
|
store := sat.NewStore()
|
||||||
|
fetch := sat.NewFetcher(dir)
|
||||||
|
fetch.Logf = applog.Printf
|
||||||
|
|
||||||
|
if els, at, err := fetch.LoadCache(); err == nil {
|
||||||
|
store.Replace(els, at)
|
||||||
|
applog.Printf("sat: %d satellites from the cached element set (%s old)", len(els), time.Since(at).Round(time.Minute))
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
applog.Printf("sat: the cached element set could not be read: %v", err)
|
||||||
|
}
|
||||||
|
a.satMu.Lock()
|
||||||
|
a.satStore, a.satBirds, a.satFetch = store, birds, fetch
|
||||||
|
a.satMu.Unlock()
|
||||||
|
a.loadCustomElements()
|
||||||
|
|
||||||
|
set := a.satSettings()
|
||||||
|
if set.AutoTLE && a.satTLEInfo().Stale {
|
||||||
|
go func() {
|
||||||
|
if _, err := a.RefreshSatelliteTLE(); err != nil {
|
||||||
|
applog.Printf("sat: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// satParts hands back the three pieces under the lock, building them if the
|
||||||
|
// startup path has not run — a binding called from a tab the operator opened
|
||||||
|
// before startup finished must not answer "no satellites".
|
||||||
|
func (a *App) satParts() (*sat.Store, *sat.Birds, *sat.Fetcher) {
|
||||||
|
a.satMu.Lock()
|
||||||
|
if a.satStore == nil {
|
||||||
|
a.satMu.Unlock()
|
||||||
|
a.startSatellites()
|
||||||
|
a.satMu.Lock()
|
||||||
|
}
|
||||||
|
s, b, f := a.satStore, a.satBirds, a.satFetch
|
||||||
|
a.satMu.Unlock()
|
||||||
|
return s, b, f
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (a *App) satSettings() SatSettings {
|
||||||
|
// The rotator defaults are the common case, not a blank form: EasyComm over
|
||||||
|
// a serial port at 9600, a 360° machine, and a five-degree step — which on a
|
||||||
|
// beam with any gain at all is well inside the beamwidth and keeps a pass
|
||||||
|
// from being a command a second.
|
||||||
|
out := SatSettings{
|
||||||
|
MinEl: 10, WindowH: 24, AutoTLE: true,
|
||||||
|
RotType: satRotEasycomm, RotPstPort: 12000,
|
||||||
|
RotTransport: "serial", RotPort: 4533, RotBaud: 9600,
|
||||||
|
RotMaxAz: 360, RotMinEl: 0, RotStep: 5,
|
||||||
|
}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
|
keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM,
|
||||||
|
keySatRotOn, keySatRotType, keySatRotPstPort, keySatRotTransport, keySatRotHost, keySatRotPort, keySatRotCOM,
|
||||||
|
keySatRotBaud, keySatRotMaxAz, keySatRotMinEl, keySatRotStep, keySatRotPark)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.RotOn = m[keySatRotOn] == "1"
|
||||||
|
if ty := m[keySatRotType]; ty == satRotPst || ty == satRotEasycomm {
|
||||||
|
out.RotType = ty
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(m[keySatRotPstPort]); err == nil && v > 0 && v <= 65535 {
|
||||||
|
out.RotPstPort = v
|
||||||
|
}
|
||||||
|
if tr := m[keySatRotTransport]; tr == "tcp" || tr == "serial" {
|
||||||
|
out.RotTransport = tr
|
||||||
|
}
|
||||||
|
out.RotHost = strings.TrimSpace(m[keySatRotHost])
|
||||||
|
if v, err := strconv.Atoi(m[keySatRotPort]); err == nil && v > 0 && v <= 65535 {
|
||||||
|
out.RotPort = v
|
||||||
|
}
|
||||||
|
out.RotCOM = strings.TrimSpace(m[keySatRotCOM])
|
||||||
|
if v, err := strconv.Atoi(m[keySatRotBaud]); err == nil && v >= 1200 && v <= 115200 {
|
||||||
|
out.RotBaud = v
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(m[keySatRotMaxAz]); err == nil && v == 450 {
|
||||||
|
out.RotMaxAz = 450
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(m[keySatRotMinEl]); err == nil && v >= -10 && v <= 30 {
|
||||||
|
out.RotMinEl = v
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(m[keySatRotStep]); err == nil && v >= 1 && v <= 30 {
|
||||||
|
out.RotStep = v
|
||||||
|
}
|
||||||
|
out.RotPark = m[keySatRotPark] == "1"
|
||||||
|
for _, n := range strings.Split(m[keySatFavorites], ",") {
|
||||||
|
if n = strings.TrimSpace(n); n != "" {
|
||||||
|
out.Favorites = append(out.Favorites, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(m[keySatMinEl]); err == nil && v >= 0 && v <= 60 {
|
||||||
|
out.MinEl = v
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(m[keySatWindowH]); err == nil && v >= 1 && v <= 168 {
|
||||||
|
out.WindowH = v
|
||||||
|
}
|
||||||
|
if v, ok := m[keySatAutoTLE]; ok && v != "" {
|
||||||
|
out.AutoTLE = v == "1"
|
||||||
|
}
|
||||||
|
out.Grid = strings.TrimSpace(m[keySatGrid])
|
||||||
|
if v, err := strconv.Atoi(m[keySatAltM]); err == nil && v > -500 && v < 9000 {
|
||||||
|
out.AltM = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatSettings returns the satellite preferences.
|
||||||
|
func (a *App) GetSatSettings() (SatSettings, error) {
|
||||||
|
if a.settings == nil {
|
||||||
|
return SatSettings{}, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
return a.satSettings(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSatSettings stores them.
|
||||||
|
func (a *App) SaveSatSettings(s SatSettings) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if s.MinEl < 0 || s.MinEl > 60 {
|
||||||
|
s.MinEl = 10
|
||||||
|
}
|
||||||
|
if s.WindowH < 1 || s.WindowH > 168 {
|
||||||
|
s.WindowH = 24
|
||||||
|
}
|
||||||
|
var favs []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, n := range s.Favorites {
|
||||||
|
n = strings.TrimSpace(n)
|
||||||
|
if n == "" || seen[strings.ToUpper(n)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[strings.ToUpper(n)] = true
|
||||||
|
favs = append(favs, n)
|
||||||
|
}
|
||||||
|
if s.RotType != satRotPst {
|
||||||
|
s.RotType = satRotEasycomm
|
||||||
|
}
|
||||||
|
if s.RotPstPort <= 0 || s.RotPstPort > 65535 {
|
||||||
|
s.RotPstPort = 12000
|
||||||
|
}
|
||||||
|
if s.RotTransport != "tcp" {
|
||||||
|
s.RotTransport = "serial"
|
||||||
|
}
|
||||||
|
if s.RotMaxAz != 450 {
|
||||||
|
s.RotMaxAz = 360
|
||||||
|
}
|
||||||
|
if s.RotStep < 1 || s.RotStep > 30 {
|
||||||
|
s.RotStep = 5
|
||||||
|
}
|
||||||
|
if s.RotPort <= 0 || s.RotPort > 65535 {
|
||||||
|
s.RotPort = 4533
|
||||||
|
}
|
||||||
|
if s.RotBaud < 1200 || s.RotBaud > 115200 {
|
||||||
|
s.RotBaud = 9600
|
||||||
|
}
|
||||||
|
for k, v := range map[string]string{
|
||||||
|
keySatFavorites: strings.Join(favs, ","),
|
||||||
|
keySatMinEl: strconv.Itoa(s.MinEl),
|
||||||
|
keySatWindowH: strconv.Itoa(s.WindowH),
|
||||||
|
keySatAutoTLE: boolStr(s.AutoTLE),
|
||||||
|
keySatGrid: strings.ToUpper(strings.TrimSpace(s.Grid)),
|
||||||
|
keySatAltM: strconv.Itoa(s.AltM),
|
||||||
|
keySatRotOn: boolStr(s.RotOn),
|
||||||
|
keySatRotType: s.RotType,
|
||||||
|
keySatRotPstPort: strconv.Itoa(s.RotPstPort),
|
||||||
|
keySatRotTransport: s.RotTransport,
|
||||||
|
keySatRotHost: strings.TrimSpace(s.RotHost),
|
||||||
|
keySatRotPort: strconv.Itoa(s.RotPort),
|
||||||
|
keySatRotCOM: strings.TrimSpace(s.RotCOM),
|
||||||
|
keySatRotBaud: strconv.Itoa(s.RotBaud),
|
||||||
|
keySatRotMaxAz: strconv.Itoa(s.RotMaxAz),
|
||||||
|
keySatRotMinEl: strconv.Itoa(s.RotMinEl),
|
||||||
|
keySatRotStep: strconv.Itoa(s.RotStep),
|
||||||
|
keySatRotPark: boolStr(s.RotPark),
|
||||||
|
} {
|
||||||
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// satObserver is the ground station: the satellite grid if the operator set one,
|
||||||
|
// otherwise the station's own.
|
||||||
|
//
|
||||||
|
// A locator, not a latitude and longitude: it is what every logbook already
|
||||||
|
// holds, and its six-character precision is a couple of kilometres — three
|
||||||
|
// hundredths of a degree of azimuth at the worst possible geometry, far below
|
||||||
|
// any rotator's backlash.
|
||||||
|
func (a *App) satObserver() (sat.Observer, error) {
|
||||||
|
set := a.satSettings()
|
||||||
|
grid := set.Grid
|
||||||
|
if grid == "" && a.profiles != nil {
|
||||||
|
// The station locator lives on the ACTIVE PROFILE, not in a settings key.
|
||||||
|
// keyStationMyGrid is a legacy key that EnsureDefault migrated into the
|
||||||
|
// profile years ago and nothing writes any more — reading it told an
|
||||||
|
// operator with a perfectly good locator on screen that he had not set
|
||||||
|
// one.
|
||||||
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
|
grid = p.MyGrid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grid = strings.TrimSpace(grid)
|
||||||
|
lat, lon, ok := gridToLatLon(grid)
|
||||||
|
if !ok {
|
||||||
|
return sat.Observer{}, fmt.Errorf("your locator is not set — Settings ▸ Station, or Settings ▸ Satellites for a different site")
|
||||||
|
}
|
||||||
|
return sat.Observer{Lat: lat, Lon: lon, AltM: float64(set.AltM)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteObserver reports the ground station the predictions are made for,
|
||||||
|
// so the UI can show it — and say plainly when there is none.
|
||||||
|
func (a *App) GetSatelliteObserver() (map[string]any, error) {
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"lat": obs.Lat, "lon": obs.Lon, "alt_m": obs.AltM}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Elements ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (a *App) customTLEPath() string { return filepath.Join(a.dataDir, customTLEName) }
|
||||||
|
|
||||||
|
// loadCustomElements merges the hand-entered file over the feed's set. Last
|
||||||
|
// writer wins in the store, so an operator's own elements for a satellite
|
||||||
|
// override the feed's — which is the whole point of having typed them.
|
||||||
|
func (a *App) loadCustomElements() int {
|
||||||
|
f, err := os.Open(a.customTLEPath())
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
els, skipped, err := sat.ParseTLESet(f)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("sat: %s could not be read: %v", customTLEName, err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if skipped > 0 {
|
||||||
|
applog.Printf("sat: %d entries in %s were unusable", skipped, customTLEName)
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
for _, e := range els {
|
||||||
|
store.Put(e)
|
||||||
|
}
|
||||||
|
return len(els)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) customElementCount() int {
|
||||||
|
f, err := os.Open(a.customTLEPath())
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
els, _, err := sat.ParseTLESet(f)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(els)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) satTLEInfo() SatTLEInfo {
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
at := store.FetchedAt()
|
||||||
|
info := SatTLEInfo{Count: store.Len(), FetchedAt: at, Custom: a.customElementCount()}
|
||||||
|
if !at.IsZero() {
|
||||||
|
info.AgeH = time.Since(at).Hours()
|
||||||
|
info.Stale = time.Since(at) > sat.StaleAfter
|
||||||
|
} else {
|
||||||
|
info.Stale = true // nothing on disk yet: the operator has to be told to fetch
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteTLEInfo describes the element set, including how old it is.
|
||||||
|
func (a *App) GetSatelliteTLEInfo() SatTLEInfo { return a.satTLEInfo() }
|
||||||
|
|
||||||
|
// RefreshSatelliteTLE downloads a fresh element set.
|
||||||
|
func (a *App) RefreshSatelliteTLE() (SatTLEInfo, error) {
|
||||||
|
store, _, fetch := a.satParts()
|
||||||
|
ctx := a.ctx
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
els, err := fetch.Fetch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return a.satTLEInfo(), err
|
||||||
|
}
|
||||||
|
store.Replace(els, time.Now())
|
||||||
|
a.loadCustomElements() // the operator's own elements go back on top
|
||||||
|
info := a.satTLEInfo()
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "sat:tle", info)
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSatelliteElements takes elements pasted in by hand — two or three lines
|
||||||
|
// per satellite — and keeps them across feed refreshes.
|
||||||
|
func (a *App) AddSatelliteElements(text string) (int, error) {
|
||||||
|
els, skipped, err := sat.ParseTLESet(strings.NewReader(text))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("those are not usable elements: %w", err)
|
||||||
|
}
|
||||||
|
existing := map[string]bool{}
|
||||||
|
var keep []sat.Element
|
||||||
|
if f, ferr := os.Open(a.customTLEPath()); ferr == nil {
|
||||||
|
old, _, _ := sat.ParseTLESet(f)
|
||||||
|
f.Close()
|
||||||
|
keep = old
|
||||||
|
}
|
||||||
|
// The new set wins for a satellite already in the file: pasting elements is
|
||||||
|
// how an operator UPDATES a bird the feeds do not carry.
|
||||||
|
for _, e := range els {
|
||||||
|
existing[strings.ToUpper(e.Name)] = true
|
||||||
|
}
|
||||||
|
var out []sat.Element
|
||||||
|
for _, e := range keep {
|
||||||
|
if !existing[strings.ToUpper(e.Name)] {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, els...)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, e := range out {
|
||||||
|
if e.Name != "" {
|
||||||
|
b.WriteString(e.Name + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString(e.Line1 + "\n" + e.Line2 + "\n")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(a.customTLEPath(), []byte(b.String()), 0o644); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := a.loadCustomElements()
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "sat:tle", a.satTLEInfo())
|
||||||
|
}
|
||||||
|
if skipped > 0 {
|
||||||
|
applog.Printf("sat: %d pasted entries were unusable and were skipped", skipped)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The list ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GetSatelliteBirds joins the frequency plan to the elements.
|
||||||
|
//
|
||||||
|
// Both halves are listed, not just their intersection: a bird with elements and
|
||||||
|
// no plan is one the operator can still track and add frequencies for, and a
|
||||||
|
// bird with a plan and no elements is the one visible symptom of an element set
|
||||||
|
// that is too old or too narrow — silently dropping either turns a fixable
|
||||||
|
// configuration problem into a satellite that "does not exist".
|
||||||
|
func (a *App) GetSatelliteBirds() []SatBird {
|
||||||
|
store, birds, _ := a.satParts()
|
||||||
|
set := a.satSettings()
|
||||||
|
fav := map[string]bool{}
|
||||||
|
for _, n := range set.Favorites {
|
||||||
|
fav[strings.ToUpper(n)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]SatBird, 0, birds.Len())
|
||||||
|
planned := map[string]bool{}
|
||||||
|
for _, b := range birds.All() {
|
||||||
|
item := SatBird{Name: b.Name, Geostationary: b.Geostationary, Favorite: fav[strings.ToUpper(b.Name)]}
|
||||||
|
for _, t := range b.Transponders {
|
||||||
|
item.Transponders = append(item.Transponders, SatTransponder{
|
||||||
|
Label: t.Label, Mode: t.Mode,
|
||||||
|
DownLo: t.DownLo, DownHi: t.DownHi, UpLo: t.UpLo, UpHi: t.UpHi,
|
||||||
|
Inverting: t.Inverting, CTCSS: t.CTCSS, Linear: t.Linear(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if e, ok := satElement(store, b); ok {
|
||||||
|
item.HasElements = true
|
||||||
|
item.NORAD = e.NORAD
|
||||||
|
item.EpochAgeH = e.Age().Hours()
|
||||||
|
planned[strings.ToUpper(e.Name)] = true
|
||||||
|
if !strings.EqualFold(e.Name, b.Name) {
|
||||||
|
item.ElementName = e.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
// The rest of the element set, so nothing the station holds is invisible.
|
||||||
|
for _, n := range store.Names() {
|
||||||
|
if planned[strings.ToUpper(n)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e, ok := store.Get(n)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, SatBird{
|
||||||
|
Name: e.Name, NORAD: e.NORAD, HasElements: true,
|
||||||
|
EpochAgeH: e.Age().Hours(), Favorite: fav[strings.ToUpper(e.Name)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
// Favourites first, then the birds we can actually use, then by name.
|
||||||
|
if out[i].Favorite != out[j].Favorite {
|
||||||
|
return out[i].Favorite
|
||||||
|
}
|
||||||
|
iu := len(out[i].Transponders) > 0 && out[i].HasElements
|
||||||
|
ju := len(out[j].Transponders) > 0 && out[j].HasElements
|
||||||
|
if iu != ju {
|
||||||
|
return iu
|
||||||
|
}
|
||||||
|
return out[i].Name < out[j].Name
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteNames is the list behind the entry form's SAT_NAME box.
|
||||||
|
//
|
||||||
|
// One list, not two. It used to be a text box in Settings ▸ Lists that an
|
||||||
|
// operator typed their birds into by hand, which then had nothing to do with
|
||||||
|
// the satellites the tracker knew — the same station kept two lists of the same
|
||||||
|
// satellites and they drifted apart. This is the followed set (or every
|
||||||
|
// satellite with a frequency plan, when none is followed), plus anything the
|
||||||
|
// old hand-kept list still holds so nobody's typing is thrown away.
|
||||||
|
//
|
||||||
|
// SAT_NAME is compared character for character by the awards and by LoTW, so
|
||||||
|
// offering the spelling already used beats inventing a new one every pass.
|
||||||
|
func (a *App) GetSatelliteNames() []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
add := func(n string) {
|
||||||
|
n = strings.ToUpper(strings.TrimSpace(n))
|
||||||
|
if n == "" || seen[n] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[n] = true
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
for _, n := range a.satNames(nil) {
|
||||||
|
add(n)
|
||||||
|
}
|
||||||
|
// The legacy list. Read, never written: the panel that edited it is gone,
|
||||||
|
// and what it holds is somebody's past work.
|
||||||
|
if a.settings != nil {
|
||||||
|
if raw, _ := a.settings.Get(a.ctx, keyListsSatellites); raw != "" {
|
||||||
|
var legacy []string
|
||||||
|
if json.Unmarshal([]byte(raw), &legacy) == nil {
|
||||||
|
for _, n := range legacy {
|
||||||
|
add(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// satElement finds the elements for a bird, trying its aliases.
|
||||||
|
//
|
||||||
|
// The feed's name and the operator's name for the same satellite are routinely
|
||||||
|
// different, and the element set is keyed by the feed's.
|
||||||
|
func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
|
||||||
|
if e, ok := store.Get(b.Name); ok {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
for _, alias := range b.Aliases {
|
||||||
|
if e, ok := store.Get(alias); ok {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Last resort: scan, matching on letters and digits alone — that is how
|
||||||
|
// "RADFXSAT (FOX-1B)" and "AO-91" meet.
|
||||||
|
for _, n := range store.Names() {
|
||||||
|
if b.Matches(n) {
|
||||||
|
if e, ok := store.Get(n); ok {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sat.Element{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tracking ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// satNames resolves the names the UI asked for, falling back to the favourites
|
||||||
|
// and then to every planned bird we hold elements for.
|
||||||
|
func (a *App) satNames(names []string) []string {
|
||||||
|
if len(names) > 0 {
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
set := a.satSettings()
|
||||||
|
if len(set.Favorites) > 0 {
|
||||||
|
return set.Favorites
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, b := range a.GetSatelliteBirds() {
|
||||||
|
if b.HasElements && len(b.Transponders) > 0 {
|
||||||
|
out = append(out, b.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// satResolve maps an operator-facing name onto the element set's own spelling.
|
||||||
|
func (a *App) satResolve(name string) (string, bool) {
|
||||||
|
store, birds, _ := a.satParts()
|
||||||
|
if _, ok := store.Get(name); ok {
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
if b, ok := birds.Find(name); ok {
|
||||||
|
if e, ok2 := satElement(store, b); ok2 {
|
||||||
|
return e.Name, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatellitePositions is where the given satellites are right now — the map's
|
||||||
|
// question, and the rotator's.
|
||||||
|
func (a *App) GetSatellitePositions(names []string) ([]sat.Position, error) {
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
var out []sat.Position
|
||||||
|
for _, n := range a.satNames(names) {
|
||||||
|
real, ok := a.satResolve(n)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p, err := store.Track(real, obs, now)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.Name = n // answer in the operator's vocabulary, not the feed's
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteGroundTrack is the path a satellite draws over the ground, for
|
||||||
|
// the map: one point a minute, forward from now.
|
||||||
|
func (a *App) GetSatelliteGroundTrack(name string, minutes int) ([]sat.Position, error) {
|
||||||
|
if minutes <= 0 || minutes > 360 {
|
||||||
|
minutes = 120
|
||||||
|
}
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
real, ok := a.satResolve(name)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s is not in the element set", name)
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
out := make([]sat.Position, 0, minutes+1)
|
||||||
|
for i := 0; i <= minutes; i++ {
|
||||||
|
p, err := store.Track(real, obs, now.Add(time.Duration(i)*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Name = name
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatellitePasses lists what is coming, in time order.
|
||||||
|
func (a *App) GetSatellitePasses(names []string, hours int) ([]sat.Pass, error) {
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
set := a.satSettings()
|
||||||
|
if hours <= 0 {
|
||||||
|
hours = set.WindowH
|
||||||
|
}
|
||||||
|
if hours > 168 {
|
||||||
|
hours = 168
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
want := a.satNames(names)
|
||||||
|
// The store is keyed by the feed's names; remember which operator name each
|
||||||
|
// answer belongs to so the table reads the way the operator thinks.
|
||||||
|
real := make([]string, 0, len(want))
|
||||||
|
back := map[string]string{}
|
||||||
|
for _, n := range want {
|
||||||
|
r, ok := a.satResolve(n)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
real = append(real, r)
|
||||||
|
back[r] = n
|
||||||
|
}
|
||||||
|
passes := store.NextPasses(real, obs, time.Now().UTC(), time.Duration(hours)*time.Hour, set.MinEl)
|
||||||
|
for i := range passes {
|
||||||
|
if n, ok := back[passes[i].Name]; ok {
|
||||||
|
passes[i].Name = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return passes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatSkyPoint is one moment of a pass as the antenna sees it.
|
||||||
|
type SatSkyPoint struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Az float64 `json:"az"`
|
||||||
|
El float64 `json:"el"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteSkyTrack is the pass drawn as a path across the sky.
|
||||||
|
//
|
||||||
|
// The map answers "where is it over the earth"; this answers "where do I look",
|
||||||
|
// which on a pass is the question that matters. An operator reading a polar
|
||||||
|
// plot knows in one glance whether the bird comes over the top or clips the
|
||||||
|
// horizon behind the house — something no amount of azimuth and elevation
|
||||||
|
// digits conveys.
|
||||||
|
func (a *App) GetSatelliteSkyTrack(name string, points int) ([]SatSkyPoint, error) {
|
||||||
|
if points < 8 || points > 400 {
|
||||||
|
points = 120
|
||||||
|
}
|
||||||
|
p, err := a.GetSatelliteNextPass(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !p.HasPass {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
real, ok := a.satResolve(name)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s is not in the element set", name)
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
span := p.LOS.Sub(p.AOS)
|
||||||
|
if span <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
out := make([]SatSkyPoint, 0, points+1)
|
||||||
|
for i := 0; i <= points; i++ {
|
||||||
|
at := p.AOS.Add(time.Duration(float64(span) * float64(i) / float64(points)))
|
||||||
|
pos, err := store.Track(real, obs, at)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Below the horizon at the very ends, by a fraction of a degree, because
|
||||||
|
// the pass boundaries come from a coarser search than this sampling. A
|
||||||
|
// negative elevation would draw the track outside the horizon circle.
|
||||||
|
if pos.El < 0 {
|
||||||
|
pos.El = 0
|
||||||
|
}
|
||||||
|
out = append(out, SatSkyPoint{At: at.UTC(), Az: pos.Az, El: pos.El})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteNextPass is the pass in progress, or the next one to come.
|
||||||
|
//
|
||||||
|
// The one question that decides whether an operator sits down at the radio, and
|
||||||
|
// the reason a satellite tab is worth having at all: how long have I got, and
|
||||||
|
// how high does it get.
|
||||||
|
func (a *App) GetSatelliteNextPass(name string) (SatPassInfo, error) {
|
||||||
|
out := SatPassInfo{Name: name}
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
real, ok := a.satResolve(name)
|
||||||
|
if !ok {
|
||||||
|
return out, fmt.Errorf("%s is not in the element set", name)
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
// From a little before now: a pass that started two minutes ago is the one
|
||||||
|
// the operator is in, and asking from this instant would skip it and report
|
||||||
|
// the next orbit instead — an hour and a half away, while the satellite is
|
||||||
|
// overhead.
|
||||||
|
from := now.Add(-30 * time.Minute)
|
||||||
|
// Elevation zero, not the operator's minimum. That threshold filters the
|
||||||
|
// table of passes worth waiting for; it must not hide the pass they are
|
||||||
|
// actually working.
|
||||||
|
passes, err := store.Passes(real, obs, from, now.Add(26*time.Hour), 0)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
for _, p := range passes {
|
||||||
|
if p.LOS.Before(now) {
|
||||||
|
continue // already over
|
||||||
|
}
|
||||||
|
out.HasPass = true
|
||||||
|
out.InPass = !p.AOS.After(now)
|
||||||
|
out.AOS, out.LOS = p.AOS, p.LOS
|
||||||
|
out.AOSAz, out.LOSAz = p.AOSAz, p.LOSAz
|
||||||
|
out.MaxEl, out.MaxElAz, out.MaxElAt = p.MaxEl, p.MaxElAz, p.MaxElAt
|
||||||
|
out.Duration = p.Duration
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteTuning is the working answer: where to listen, where to transmit,
|
||||||
|
// and where the bird is, for one satellite and one transponder.
|
||||||
|
//
|
||||||
|
// downHz is where the operator has tuned inside the passband, in NOMINAL terms
|
||||||
|
// — 0 means the middle of it. Keeping the operator's frequency nominal, and
|
||||||
|
// applying Doppler only on the way out to the radio, is what makes a linear
|
||||||
|
// pass workable: the station being answered stays put on the dial while both
|
||||||
|
// radios chase the shift.
|
||||||
|
func (a *App) GetSatelliteTuning(name string, transponder int, downHz int64) (SatTuning, error) {
|
||||||
|
_, birds, _ := a.satParts()
|
||||||
|
b, ok := birds.Find(name)
|
||||||
|
if !ok {
|
||||||
|
return SatTuning{}, fmt.Errorf("%s has no frequency plan — add one in %s", name, sat.BirdsName)
|
||||||
|
}
|
||||||
|
if transponder < 0 || transponder >= len(b.Transponders) {
|
||||||
|
transponder = 0
|
||||||
|
}
|
||||||
|
if len(b.Transponders) == 0 {
|
||||||
|
return SatTuning{}, fmt.Errorf("%s has no transponder listed", b.Name)
|
||||||
|
}
|
||||||
|
t := b.Transponders[transponder]
|
||||||
|
if downHz <= 0 {
|
||||||
|
// While the tracker is running it owns the nominal frequency — it moves
|
||||||
|
// as the operator tunes. Reading the centre of the passband instead would
|
||||||
|
// show a frequency nobody is on the moment they hunt for a station.
|
||||||
|
downHz = a.satTrackedNominal(b.Name, transponder)
|
||||||
|
}
|
||||||
|
if downHz <= 0 {
|
||||||
|
downHz = t.Centre()
|
||||||
|
}
|
||||||
|
out := SatTuning{
|
||||||
|
Name: b.Name,
|
||||||
|
Transponder: t.Label,
|
||||||
|
Mode: t.Mode,
|
||||||
|
NominalDown: downHz,
|
||||||
|
NominalUp: t.UplinkFor(downHz),
|
||||||
|
CTCSS: t.CTCSS,
|
||||||
|
Inverting: t.Inverting,
|
||||||
|
At: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
// Geostationary: it does not move, so there is nothing to correct and no
|
||||||
|
// look angle worth recomputing every second. QO-100 is simply pointed at
|
||||||
|
// once and left alone.
|
||||||
|
if b.Geostationary {
|
||||||
|
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
|
||||||
|
out.Visible = true
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
// No locator: the frequencies are still worth having, uncorrected.
|
||||||
|
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
real, ok := a.satResolve(name)
|
||||||
|
if !ok {
|
||||||
|
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
|
||||||
|
return out, fmt.Errorf("%s is not in the element set — refresh the elements", b.Name)
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
p, err := store.Track(real, obs, out.At)
|
||||||
|
if err != nil {
|
||||||
|
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
sh := sat.Doppler(p, out.NominalDown, out.NominalUp)
|
||||||
|
out.DownHz, out.UpHz = sh.DownHz, sh.UpHz
|
||||||
|
out.Az, out.El, out.RangeKm, out.RangeRate = p.Az, p.El, p.RangeKm, p.RangeRate
|
||||||
|
out.Lat, out.Lon, out.AltKm, out.Footprint = p.Lat, p.Lon, p.AltKm, p.Footprint
|
||||||
|
out.Visible = p.Visible()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The two ways a satellite station points its antenna.
|
||||||
|
//
|
||||||
|
// Some operators drive their az/el rotator directly — EasyComm II, what
|
||||||
|
// SatPC32 and Gpredict speak. Others already run PstRotator, which sits between
|
||||||
|
// them and a dozen different controllers and handles az AND el; for those,
|
||||||
|
// OpsLog talking to the controller itself would be a second program fighting
|
||||||
|
// PstRotator over the same cable.
|
||||||
|
//
|
||||||
|
// So both, behind one small interface, chosen in Settings. Neither is more
|
||||||
|
// "correct" than the other: the right one is whichever the station already has
|
||||||
|
// working.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"hamlog/internal/rotator/easycomm"
|
||||||
|
"hamlog/internal/rotator/pst"
|
||||||
|
)
|
||||||
|
|
||||||
|
// satRotator is what the tracker needs of an antenna: point it, ask where it
|
||||||
|
// is, and let go of it at the end of the pass.
|
||||||
|
type satRotator interface {
|
||||||
|
Point(az, el float64) error
|
||||||
|
// Heading reports where the antenna is. live is false when the answer is
|
||||||
|
// the last commanded position rather than a reading — a stuck rotator must
|
||||||
|
// not be able to hide behind an order it never carried out.
|
||||||
|
Heading() (az, el float64, live bool, err error)
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rotator kinds, as stored.
|
||||||
|
const (
|
||||||
|
satRotEasycomm = "easycomm"
|
||||||
|
satRotPst = "pstrotator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newSatRotator builds the configured controller.
|
||||||
|
func newSatRotator(s SatSettings) (satRotator, error) {
|
||||||
|
switch s.RotType {
|
||||||
|
case satRotPst:
|
||||||
|
if strings.TrimSpace(s.RotHost) == "" && s.RotPort <= 0 {
|
||||||
|
return nil, fmt.Errorf("no address for PstRotator")
|
||||||
|
}
|
||||||
|
return &pstSatRotator{c: pst.New(s.RotHost, s.RotPstPort), maxAz: s.RotMaxAz}, nil
|
||||||
|
default:
|
||||||
|
if s.RotTransport == "tcp" {
|
||||||
|
if strings.TrimSpace(s.RotHost) == "" {
|
||||||
|
return nil, fmt.Errorf("no address for the rotator")
|
||||||
|
}
|
||||||
|
return easycomm.New(s.RotHost, s.RotPort, s.RotMaxAz), nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.RotCOM) == "" {
|
||||||
|
return nil, fmt.Errorf("no COM port for the rotator")
|
||||||
|
}
|
||||||
|
return easycomm.NewSerial(s.RotCOM, s.RotBaud, s.RotMaxAz), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pstSatRotator points the antenna through PstRotator.
|
||||||
|
//
|
||||||
|
// PstRotator takes whole degrees and does its own overlap handling for a 450°
|
||||||
|
// rotator — it knows which controller is on the other end, and OpsLog does not.
|
||||||
|
// So the azimuth is sent plainly, and the 450° logic that EasyComm needs is
|
||||||
|
// deliberately NOT applied here: two programs each deciding to go the long way
|
||||||
|
// round is how an antenna ends up unwinding in the middle of a pass.
|
||||||
|
type pstSatRotator struct {
|
||||||
|
c *pst.Client
|
||||||
|
maxAz int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// lastAz/lastEl are what was commanded, for the display when PstRotator
|
||||||
|
// does not answer a position query — which is the usual case for the many
|
||||||
|
// setups whose controller reports nothing back to it either.
|
||||||
|
lastAz, lastEl float64
|
||||||
|
commanded bool
|
||||||
|
azSilent bool // the azimuth query went unanswered; stop asking
|
||||||
|
elSilent bool // likewise for elevation, and far more common
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pstSatRotator) Point(az, el float64) error {
|
||||||
|
a := math.Mod(az, 360)
|
||||||
|
if a < 0 {
|
||||||
|
a += 360
|
||||||
|
}
|
||||||
|
if el < 0 {
|
||||||
|
el = 0
|
||||||
|
}
|
||||||
|
if el > 180 {
|
||||||
|
el = 180
|
||||||
|
}
|
||||||
|
if err := p.c.GoTo(int(math.Round(a)), true, int(math.Round(el))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
p.lastAz, p.lastEl, p.commanded = a, el, true
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pstSatRotator) Heading() (float64, float64, bool, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
azSilent, elSilent, la, le, commanded := p.azSilent, p.elSilent, p.lastAz, p.lastEl, p.commanded
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
az, el, live := la, le, false
|
||||||
|
if !azSilent {
|
||||||
|
if v, _, err := p.c.Heading(); err == nil {
|
||||||
|
az, live = float64(v), true
|
||||||
|
} else {
|
||||||
|
// One silence is enough. Each query binds a socket and waits a second
|
||||||
|
// and a half; repeating that every few seconds for a setup that will
|
||||||
|
// never answer is a stall per poll for nothing.
|
||||||
|
p.mu.Lock()
|
||||||
|
p.azSilent = true
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !elSilent {
|
||||||
|
if v, _, err := p.c.Elevation(); err == nil {
|
||||||
|
el = float64(v)
|
||||||
|
} else {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.elSilent = true
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !live && !commanded {
|
||||||
|
return 0, 0, false, fmt.Errorf("PstRotator does not report the antenna position")
|
||||||
|
}
|
||||||
|
return az, el, live, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close: nothing to release. Every PstRotator command is one datagram, and the
|
||||||
|
// socket lives for the length of a single write.
|
||||||
|
func (p *pstSatRotator) Close() {}
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Doppler tracking — walking the radio through a pass.
|
||||||
|
//
|
||||||
|
// The hard part of satellite tuning is not the arithmetic, it is deciding who
|
||||||
|
// owns the dial. A tracker that simply forces both frequencies fights the
|
||||||
|
// operator every time they turn the knob to follow a station across a linear
|
||||||
|
// transponder, and one that never touches the receiver leaves them chasing a
|
||||||
|
// signal that slides 9 kHz across a 70 cm pass.
|
||||||
|
//
|
||||||
|
// So: the operator owns the receiver, and the tracker follows them. Every tick
|
||||||
|
// it asks the radio where the receiver actually is. If that is where the tracker
|
||||||
|
// put it, nothing has changed and it keeps correcting from the same NOMINAL
|
||||||
|
// frequency. If it has moved further than a dial-turn's tolerance, the operator
|
||||||
|
// has chosen a new station: the tracker converts what they landed on back into a
|
||||||
|
// nominal frequency and carries on from there. The transmitter is derived from
|
||||||
|
// the nominal and never argued with — which is exactly the division of labour on
|
||||||
|
// a linear bird, where the operator listens and the radio does the sums.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
"hamlog/internal/sat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// satTickEvery is how often the radio is re-pointed. One second: at the middle
|
||||||
|
// of a 70 cm pass the downlink moves about 60 Hz a second, which is audible on
|
||||||
|
// SSB within two or three of them and inaudible within one.
|
||||||
|
const satTickEvery = time.Second
|
||||||
|
|
||||||
|
// satDialTolerance is how far the receiver may differ from where the tracker put
|
||||||
|
// it before that difference is read as the operator tuning.
|
||||||
|
//
|
||||||
|
// 200 Hz is comfortably more than the rounding and the round-trip lag between
|
||||||
|
// setting a frequency and reading it back, and comfortably less than the
|
||||||
|
// smallest deliberate move anybody makes hunting a station on a transponder.
|
||||||
|
const satDialTolerance = 200
|
||||||
|
|
||||||
|
// satLightKmS is the speed of light in km/s, for turning a heard frequency back
|
||||||
|
// into a nominal one. The same constant internal/sat corrects with.
|
||||||
|
const satLightKmS = 299792.458
|
||||||
|
|
||||||
|
type satTracker struct {
|
||||||
|
name string
|
||||||
|
tp int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// nominalDown is where the operator is, expressed as if the satellite were
|
||||||
|
// standing still. Everything else is derived from it, and it is the only
|
||||||
|
// thing a dial movement changes.
|
||||||
|
nominalDown int64
|
||||||
|
lastDown int64 // what was last sent to the radio
|
||||||
|
lastUp int64
|
||||||
|
status SatTrackStatus
|
||||||
|
fails int
|
||||||
|
|
||||||
|
// The az/el rotator, built once at the start of the pass so a serial port is
|
||||||
|
// opened once rather than on every command. nil when none is configured.
|
||||||
|
rot satRotator
|
||||||
|
rotStep float64
|
||||||
|
rotMinE float64
|
||||||
|
rotPark bool
|
||||||
|
rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing
|
||||||
|
rotEl float64
|
||||||
|
rotSent bool
|
||||||
|
rotReadAt time.Time // when the controller was last asked where it is
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatTrackStatus is what the tracker is doing, for the panel.
|
||||||
|
type SatTrackStatus struct {
|
||||||
|
On bool `json:"on"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Transponder string `json:"transponder"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
NominalDown int64 `json:"nominal_down"`
|
||||||
|
NominalUp int64 `json:"nominal_up"`
|
||||||
|
DownHz int64 `json:"down_hz"`
|
||||||
|
UpHz int64 `json:"up_hz"`
|
||||||
|
Az float64 `json:"az"`
|
||||||
|
El float64 `json:"el"`
|
||||||
|
Visible bool `json:"visible"`
|
||||||
|
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
|
||||||
|
Error string `json:"error"`
|
||||||
|
|
||||||
|
// Where the antenna is. RotLive distinguishes a reading from the controller
|
||||||
|
// from the last position it was TOLD to go to — a stuck rotator must not be
|
||||||
|
// able to hide behind a command it never carried out.
|
||||||
|
RotOn bool `json:"rot_on"`
|
||||||
|
RotAz float64 `json:"rot_az"`
|
||||||
|
RotEl float64 `json:"rot_el"`
|
||||||
|
RotLive bool `json:"rot_live"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSatelliteTracking arms the radio and starts following the satellite.
|
||||||
|
func (a *App) StartSatelliteTracking(name string, transponder int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("CAT is not running")
|
||||||
|
}
|
||||||
|
_, birds, _ := a.satParts()
|
||||||
|
b, ok := birds.Find(name)
|
||||||
|
if !ok || len(b.Transponders) == 0 {
|
||||||
|
return fmt.Errorf("%s has no frequency plan to tune to", name)
|
||||||
|
}
|
||||||
|
if transponder < 0 || transponder >= len(b.Transponders) {
|
||||||
|
transponder = 0
|
||||||
|
}
|
||||||
|
a.StopSatelliteTracking()
|
||||||
|
|
||||||
|
t := &satTracker{
|
||||||
|
name: b.Name,
|
||||||
|
tp: transponder,
|
||||||
|
nominalDown: b.Transponders[transponder].Centre(),
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
|
||||||
|
|
||||||
|
// The rotator, if there is one. A geostationary bird is pointed at once and
|
||||||
|
// left alone, so it gets one command rather than a loop.
|
||||||
|
set := a.satSettings()
|
||||||
|
if set.RotOn {
|
||||||
|
r, rerr := newSatRotator(set)
|
||||||
|
if rerr != nil {
|
||||||
|
applog.Printf("sat: no rotator: %v", rerr)
|
||||||
|
t.status.Error = rerr.Error()
|
||||||
|
} else {
|
||||||
|
t.rot = r
|
||||||
|
t.rotStep, t.rotMinE, t.rotPark = float64(set.RotStep), float64(set.RotMinEl), set.RotPark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arm the radio for the pair. A rig that cannot hold one is NOT a failure:
|
||||||
|
// it can still be tuned to the downlink, which is most of a receive-heavy
|
||||||
|
// pass, and saying so beats refusing to track at all.
|
||||||
|
radio := "downlink-only"
|
||||||
|
if a.cat.SatCapable() {
|
||||||
|
if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(true) }); err != nil {
|
||||||
|
applog.Printf("sat: could not arm satellite mode: %v", err)
|
||||||
|
t.status.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
radio = "sat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.status.Radio = radio
|
||||||
|
|
||||||
|
a.satTrackMu.Lock()
|
||||||
|
a.satTrack = t
|
||||||
|
a.satTrackMu.Unlock()
|
||||||
|
go a.satTrackLoop(t)
|
||||||
|
applog.Printf("sat: tracking %s (%s), radio %s", t.name, t.status.Transponder, radio)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopSatelliteTracking hands the radio back.
|
||||||
|
func (a *App) StopSatelliteTracking() {
|
||||||
|
a.satTrackMu.Lock()
|
||||||
|
t := a.satTrack
|
||||||
|
a.satTrack = nil
|
||||||
|
a.satTrackMu.Unlock()
|
||||||
|
if t == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
close(t.stop)
|
||||||
|
<-t.done
|
||||||
|
if a.cat != nil && a.cat.SatCapable() {
|
||||||
|
if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(false) }); err != nil {
|
||||||
|
applog.Printf("sat: could not disarm satellite mode: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applog.Printf("sat: tracking stopped (%s)", t.name)
|
||||||
|
a.emitSatTrack(SatTrackStatus{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSatelliteRotator opens the configured controller and asks it where it is.
|
||||||
|
//
|
||||||
|
// The one question worth asking before a pass: is this port the rotator, and
|
||||||
|
// does it talk back? A controller that accepts commands silently is a normal,
|
||||||
|
// working one — so that answer is a success with a caveat, not a failure.
|
||||||
|
func (a *App) TestSatelliteRotator() (string, error) {
|
||||||
|
set := a.satSettings()
|
||||||
|
if !set.RotOn {
|
||||||
|
return "", fmt.Errorf("the satellite rotator is switched off")
|
||||||
|
}
|
||||||
|
c, err := newSatRotator(set)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
az, el, live, err := c.Heading()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !live {
|
||||||
|
return "The controller accepted the command but does not report its position — normal for many controllers. It will still be driven.", nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("The rotator is at %.1f° azimuth, %.1f° elevation.", az, el), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteTracking reports what the tracker is doing.
|
||||||
|
func (a *App) GetSatelliteTracking() SatTrackStatus {
|
||||||
|
a.satTrackMu.Lock()
|
||||||
|
t := a.satTrack
|
||||||
|
a.satTrackMu.Unlock()
|
||||||
|
if t == nil {
|
||||||
|
return SatTrackStatus{}
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.status
|
||||||
|
}
|
||||||
|
|
||||||
|
// satTrackedNominal is the nominal downlink the tracker is currently working
|
||||||
|
// from, or 0 when it is not tracking this satellite and transponder.
|
||||||
|
func (a *App) satTrackedNominal(name string, transponder int) int64 {
|
||||||
|
a.satTrackMu.Lock()
|
||||||
|
t := a.satTrack
|
||||||
|
a.satTrackMu.Unlock()
|
||||||
|
if t == nil || t.tp != transponder || !strings.EqualFold(t.name, name) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.nominalDown
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) emitSatTrack(s SatTrackStatus) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "sat:track", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) satTrackLoop(t *satTracker) {
|
||||||
|
defer close(t.done)
|
||||||
|
defer t.releaseRotator()
|
||||||
|
tick := time.NewTicker(satTickEvery)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
a.satTrackStep(t)
|
||||||
|
select {
|
||||||
|
case <-t.stop:
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// satTrackStep is one pass of the loop: read the dial, work out the pair, send
|
||||||
|
// what changed.
|
||||||
|
func (a *App) satTrackStep(t *satTracker) {
|
||||||
|
_, birds, _ := a.satParts()
|
||||||
|
b, ok := birds.Find(t.name)
|
||||||
|
if !ok || t.tp >= len(b.Transponders) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tp := b.Transponders[t.tp]
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
nominal := t.nominalDown
|
||||||
|
lastDown, lastUp := t.lastDown, t.lastUp
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
// Where the satellite is, and how fast it is running away. A geostationary
|
||||||
|
// bird is neither: its range rate is zero, so the zero position below gives
|
||||||
|
// a zero shift without a special case, and asking for a look angle we do not
|
||||||
|
// need would only fail on a station with no locator.
|
||||||
|
var pos sat.Position
|
||||||
|
visible := true
|
||||||
|
if !b.Geostationary {
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
t.setError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
real, ok := a.satResolve(t.name)
|
||||||
|
if !ok {
|
||||||
|
t.setError(fmt.Sprintf("%s is not in the element set", t.name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
p, err := store.Track(real, obs, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.setError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pos = p
|
||||||
|
visible = p.Visible()
|
||||||
|
}
|
||||||
|
// The fractional shift, positive when the satellite is approaching. Only the
|
||||||
|
// dial arithmetic below needs it as a number; the pair itself comes from
|
||||||
|
// sat.Doppler, so there is exactly one place where the sign of a correction
|
||||||
|
// is decided.
|
||||||
|
factor := -pos.RangeRate / satLightKmS
|
||||||
|
|
||||||
|
// Where did the operator leave the receiver? If it is not where the tracker
|
||||||
|
// put it, they have moved to another station and that is the new nominal.
|
||||||
|
if lastDown > 0 && tp.Linear() {
|
||||||
|
if actual, err := a.satReceiveHz(); err == nil && actual > 0 {
|
||||||
|
if abs64i(actual-lastDown) > satDialTolerance {
|
||||||
|
moved := satNominalFromDial(actual, factor)
|
||||||
|
if moved >= tp.DownLo && moved <= tp.DownHi {
|
||||||
|
nominal = moved
|
||||||
|
t.mu.Lock()
|
||||||
|
t.nominalDown = moved
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nomUp := tp.UplinkFor(nominal)
|
||||||
|
sh := sat.Doppler(pos, nominal, nomUp)
|
||||||
|
down, up := sh.DownHz, sh.UpHz
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
t.status = SatTrackStatus{
|
||||||
|
On: true, Name: b.Name, Transponder: tp.Label, Mode: tp.Mode,
|
||||||
|
NominalDown: nominal, NominalUp: nomUp,
|
||||||
|
DownHz: down, UpHz: up,
|
||||||
|
Az: pos.Az, El: pos.El, Visible: visible,
|
||||||
|
Radio: t.status.Radio, Error: t.status.Error,
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
t.pointRotator(pos, b.Geostationary)
|
||||||
|
t.readRotator()
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
st := t.status
|
||||||
|
t.mu.Unlock()
|
||||||
|
a.emitSatTrack(st)
|
||||||
|
|
||||||
|
// Only send what has actually moved. The step is the smallest change worth a
|
||||||
|
// command: on SSB a listener hears twenty hertz, on an FM channel nothing
|
||||||
|
// under a couple of hundred matters at all.
|
||||||
|
step := int64(20)
|
||||||
|
if strings.EqualFold(tp.Mode, "FM") {
|
||||||
|
step = 200
|
||||||
|
}
|
||||||
|
if abs64i(down-lastDown) < step && abs64i(up-lastUp) < step {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := tp.Mode
|
||||||
|
if lastDown != 0 {
|
||||||
|
mode = "" // set once, at the start of the pass — see satMode/satSetMode
|
||||||
|
}
|
||||||
|
err := a.satTune(down, up, mode, mode)
|
||||||
|
t.mu.Lock()
|
||||||
|
if err == nil {
|
||||||
|
t.lastDown, t.lastUp, t.fails = down, up, 0
|
||||||
|
t.status.Error = ""
|
||||||
|
} else {
|
||||||
|
t.fails++
|
||||||
|
t.status.Error = err.Error()
|
||||||
|
}
|
||||||
|
fails := t.fails
|
||||||
|
t.mu.Unlock()
|
||||||
|
if err != nil && (fails == 1 || fails%30 == 0) {
|
||||||
|
// Once, then once every half minute: a radio that has gone away must be
|
||||||
|
// visible in the log without filling it.
|
||||||
|
applog.Printf("sat: tuning %s failed (%d in a row): %v", t.name, fails, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// satNominalFromDial turns a frequency the operator tuned to into the nominal
|
||||||
|
// one it corresponds to.
|
||||||
|
//
|
||||||
|
// The inverse of the downlink correction: what comes out of the transponder at
|
||||||
|
// nominal arrives at heard = nominal × (1 + f). Doing this is what lets the
|
||||||
|
// operator hunt across a linear passband without the tracker dragging them back
|
||||||
|
// — where they land becomes the new truth, and the uplink follows it.
|
||||||
|
func satNominalFromDial(heardHz int64, factor float64) int64 {
|
||||||
|
if heardHz <= 0 || factor <= -1 {
|
||||||
|
return heardHz
|
||||||
|
}
|
||||||
|
return int64(math.Round(float64(heardHz) / (1 + factor)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// pointRotator keeps the antenna on the satellite.
|
||||||
|
//
|
||||||
|
// Below the configured elevation the rotator is left alone. Not because the
|
||||||
|
// numbers stop being right — they are right all the way round the orbit — but
|
||||||
|
// because a rotator that chases a satellite through the far side of the earth
|
||||||
|
// spends the whole night turning, and a mast is a mechanical thing with a
|
||||||
|
// finite number of turns in it.
|
||||||
|
func (t *satTracker) pointRotator(pos sat.Position, geostationary bool) {
|
||||||
|
if t.rot == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !geostationary && pos.El < t.rotMinE {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A step below the beamwidth is a command for nothing. Compared against what
|
||||||
|
// was last COMMANDED rather than where the rotator says it is: a rotator in
|
||||||
|
// motion is always somewhere between the two, and comparing against that
|
||||||
|
// would order a fresh move on every tick of a slew.
|
||||||
|
az, el := pos.Az, pos.El
|
||||||
|
if geostationary {
|
||||||
|
// A satellite that does not move needs pointing once. Its own az/el were
|
||||||
|
// not computed (there is nothing to compute), so leave the rotator where
|
||||||
|
// the operator put it.
|
||||||
|
if t.rotSent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.rotSent && math.Abs(az-t.rotAz) < t.rotStep && math.Abs(el-t.rotEl) < t.rotStep {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := t.rot.Point(az, el); err != nil {
|
||||||
|
t.setError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.rotAz, t.rotEl, t.rotSent = az, el, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRotator asks the controller where it actually is, for the display.
|
||||||
|
//
|
||||||
|
// Separate from the pointing, and it runs on every tick rather than only when a
|
||||||
|
// command was sent: watching the antenna crawl towards the bearing is how an
|
||||||
|
// operator sees a rotator that is slow, stalled, or turning the wrong way. A
|
||||||
|
// controller that does not answer says so once and is not asked again.
|
||||||
|
func (t *satTracker) readRotator() {
|
||||||
|
if t.rot == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not on every tick. A PstRotator query binds a socket and waits up to a
|
||||||
|
// second and a half for an answer, and a held serial port still costs a
|
||||||
|
// round trip; three seconds is often enough to watch an antenna slew and
|
||||||
|
// rare enough not to sit in the way of the tuning.
|
||||||
|
if time.Since(t.rotReadAt) < 3*time.Second {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.rotReadAt = time.Now()
|
||||||
|
az, el, live, err := t.rot.Heading()
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
t.status.RotOn = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.status.RotOn, t.status.RotAz, t.status.RotEl, t.status.RotLive = true, az, el, live
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseRotator hands the mast back at the end of a pass.
|
||||||
|
func (t *satTracker) releaseRotator() {
|
||||||
|
if t.rot == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t.rotPark && t.rotSent {
|
||||||
|
// Elevation down first and azimuth to north: a dish or a pair of yagis
|
||||||
|
// left pointing at the sky is what a gale takes away.
|
||||||
|
if err := t.rot.Point(0, 0); err != nil {
|
||||||
|
applog.Printf("sat: could not park the rotator: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.rot.Close()
|
||||||
|
t.rot = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *satTracker) setError(msg string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.status.Error = msg
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// satTune sends the pair to whichever radio is connected.
|
||||||
|
func (a *App) satTune(downHz, upHz int64, downMode, upMode string) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("CAT is not running")
|
||||||
|
}
|
||||||
|
if a.cat.SatCapable() {
|
||||||
|
return a.cat.SatDo(func(st cat.SatTuner) error {
|
||||||
|
return st.TuneSatellite(downHz, upHz, downMode, upMode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// No satellite pair on this backend: the downlink is what it can do, and the
|
||||||
|
// operator was told so when tracking started (Radio = "downlink-only").
|
||||||
|
if err := a.cat.SetFrequency(downHz); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if downMode != "" {
|
||||||
|
return a.cat.SetMode(downMode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// satReceiveHz is where the receiver is, asked of the backend that knows.
|
||||||
|
func (a *App) satReceiveHz() (int64, error) {
|
||||||
|
if a.cat == nil {
|
||||||
|
return 0, fmt.Errorf("CAT is not running")
|
||||||
|
}
|
||||||
|
if a.cat.SatCapable() {
|
||||||
|
var hz int64
|
||||||
|
err := a.cat.SatDo(func(st cat.SatTuner) error {
|
||||||
|
v, e := st.SatReceiveHz()
|
||||||
|
hz = v
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
return hz, err
|
||||||
|
}
|
||||||
|
st := a.cat.State()
|
||||||
|
if st.RxFreqHz > 0 {
|
||||||
|
return st.RxFreqHz, nil
|
||||||
|
}
|
||||||
|
return st.FreqHz, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs64i(v int64) int64 {
|
||||||
|
if v < 0 {
|
||||||
|
return -v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What goes in the log ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// applySatellite stamps a QSO made through a satellite.
|
||||||
|
//
|
||||||
|
// The NOMINAL frequencies are logged, never the Doppler-corrected ones. Two
|
||||||
|
// stations working each other through a transponder read different numbers off
|
||||||
|
// their dials at the same instant — that is what Doppler means — and the only
|
||||||
|
// figure they can both agree on, and the only one that means anything to
|
||||||
|
// somebody reading the log later, is the transponder's own. LoTW matches on the
|
||||||
|
// band, so nothing is lost; a log full of 435.847 231 would simply be a record
|
||||||
|
// of where one radio happened to be.
|
||||||
|
func (a *App) applySatellite(q *qso.QSO) {
|
||||||
|
a.satTrackMu.Lock()
|
||||||
|
t := a.satTrack
|
||||||
|
a.satTrackMu.Unlock()
|
||||||
|
if t == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
name, down, up := t.status.Name, t.status.NominalDown, t.status.NominalUp
|
||||||
|
az, el := t.status.Az, t.status.El
|
||||||
|
t.mu.Unlock()
|
||||||
|
if name == "" || down <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Nothing the operator filled in is overwritten. A QSO edited by hand, or
|
||||||
|
// imported, or logged from a second radio while the tracker happened to be
|
||||||
|
// running, keeps what it was given.
|
||||||
|
if strings.TrimSpace(q.PropMode) == "" {
|
||||||
|
q.PropMode = "SAT"
|
||||||
|
}
|
||||||
|
if q.PropMode != "SAT" {
|
||||||
|
return // they said it was something else — meteor scatter, EME
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(q.SatName) == "" {
|
||||||
|
q.SatName = name
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(q.SatMode) == "" {
|
||||||
|
q.SatMode = satModeLetters(up, down)
|
||||||
|
}
|
||||||
|
// The transmit frequency is the uplink and the receive frequency the
|
||||||
|
// downlink — which is the one place a satellite QSO differs from every other
|
||||||
|
// kind, and the reason FREQ alone cannot describe one.
|
||||||
|
if up > 0 {
|
||||||
|
q.FreqHz = &up
|
||||||
|
if b := bandForHz(up); b != "" {
|
||||||
|
q.Band = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d := down
|
||||||
|
q.FreqRXHz = &d
|
||||||
|
if b := bandForHz(down); b != "" {
|
||||||
|
q.BandRX = b
|
||||||
|
}
|
||||||
|
if q.AntAz == nil && (az != 0 || el != 0) {
|
||||||
|
v := az
|
||||||
|
q.AntAz = &v
|
||||||
|
}
|
||||||
|
if q.AntEl == nil && el != 0 {
|
||||||
|
v := el
|
||||||
|
q.AntEl = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// satModeLetters is the ADIF SAT_MODE: the uplink band's letter, then the
|
||||||
|
// downlink's — "U/V" for 435 up, 145 down. The letters are AMSAT's, and they
|
||||||
|
// are what every satellite operator writes on a QSL card.
|
||||||
|
func satModeLetters(upHz, downHz int64) string {
|
||||||
|
u, d := satBandLetter(upHz), satBandLetter(downHz)
|
||||||
|
if u == "" || d == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u + "/" + d
|
||||||
|
}
|
||||||
|
|
||||||
|
func satBandLetter(hz int64) string {
|
||||||
|
switch {
|
||||||
|
case hz <= 0:
|
||||||
|
return ""
|
||||||
|
case hz < 30_000_000:
|
||||||
|
return "A" // 10 m — mode A's downlink
|
||||||
|
case hz < 148_000_000:
|
||||||
|
return "V" // 2 m
|
||||||
|
case hz < 450_000_000:
|
||||||
|
return "U" // 70 cm
|
||||||
|
case hz < 1_300_000_000:
|
||||||
|
return "L" // 23 cm
|
||||||
|
case hz < 2_500_000_000:
|
||||||
|
return "S" // 13 cm
|
||||||
|
case hz < 6_000_000_000:
|
||||||
|
return "C" // 6 cm
|
||||||
|
case hz < 11_000_000_000:
|
||||||
|
return "X" // 3 cm
|
||||||
|
}
|
||||||
|
return "K" // 24 GHz and above
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/sat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The dial arithmetic has to be the exact inverse of the correction, or every
|
||||||
|
// touch of the knob would nudge the nominal frequency a little further off and
|
||||||
|
// the uplink would walk across the passband over a pass.
|
||||||
|
func TestSatNominalFromDialRoundTrip(t *testing.T) {
|
||||||
|
// A range of range rates: hard approach, drifting, hard recession. ±8 km/s
|
||||||
|
// covers a low orbit overhead.
|
||||||
|
for _, rate := range []float64{-8, -3.2, -0.4, 0, 0.4, 3.2, 8} {
|
||||||
|
p := sat.Position{RangeRate: rate}
|
||||||
|
for _, nominal := range []int64{29_450_000, 145_900_000, 435_850_000, 10_489_675_000} {
|
||||||
|
sh := sat.Doppler(p, nominal, 0)
|
||||||
|
factor := -rate / satLightKmS
|
||||||
|
got := satNominalFromDial(sh.DownHz, factor)
|
||||||
|
if diff := got - nominal; diff > 1 || diff < -1 {
|
||||||
|
t.Errorf("rate %.1f km/s, %d Hz: heard %d, came back as %d (%+d)",
|
||||||
|
rate, nominal, sh.DownHz, got, diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAT_MODE is what goes on a QSL card, and the letters are the uplink's then
|
||||||
|
// the downlink's — the order operators write and the order ADIF wants.
|
||||||
|
func TestSatModeLetters(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
up, down int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"FO-29: 2 m up, 70 cm down", 145_950_000, 435_850_000, "V/U"},
|
||||||
|
{"AO-91: 70 cm up, 2 m down", 435_250_000, 145_960_000, "U/V"},
|
||||||
|
{"AO-7 mode A: 2 m up, 10 m down", 145_900_000, 29_450_000, "V/A"},
|
||||||
|
{"QO-100: 13 cm up, 3 cm down", 2_400_175_000, 10_489_675_000, "S/X"},
|
||||||
|
{"receive only", 0, 145_800_000, ""},
|
||||||
|
} {
|
||||||
|
if got := satModeLetters(tc.up, tc.down); got != tc.want {
|
||||||
|
t.Errorf("%s: got %q, wanted %q", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ var sensitiveSettingKeys = map[string]bool{
|
|||||||
keyExtLoTWKeyPassword: true,
|
keyExtLoTWKeyPassword: true,
|
||||||
keyExtLoTWWebPassword: true,
|
keyExtLoTWWebPassword: true,
|
||||||
keyExtHRDLogCode: true,
|
keyExtHRDLogCode: true,
|
||||||
|
keyExtHamqthPassword: true,
|
||||||
keyExtEQSLPassword: true,
|
keyExtEQSLPassword: true,
|
||||||
keyExtCloudlogAPIKey: true,
|
keyExtCloudlogAPIKey: true,
|
||||||
// The web-publish config is one JSON blob and the FTP password lives inside
|
// The web-publish config is one JSON blob and the FTP password lives inside
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The voice keyer, through the radio's own link.
|
||||||
|
//
|
||||||
|
// Selecting the radio as the "To radio" output makes the voice keyer hand its
|
||||||
|
// messages to the CAT backend instead of a sound card. Everything around it is
|
||||||
|
// unchanged — the same PTT before and after, the same gain, the same files —
|
||||||
|
// which is the point: the audio takes a different road, not a different route.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/audio"
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tciTXPlayer hands one message to the radio.
|
||||||
|
//
|
||||||
|
// The controller is fetched on the CAT goroutine and the message is then played
|
||||||
|
// OFF it. Playing on it would hold that goroutine for the length of the
|
||||||
|
// message, and everything else about the rig — frequency, mode, PTT state —
|
||||||
|
// goes through the same place: a ten-second call would freeze the display and
|
||||||
|
// the antenna following for ten seconds. The TCI backend serialises its own
|
||||||
|
// writes, so this is safe to call from here.
|
||||||
|
func (a *App) tciTXPlayer(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("CAT not initialized")
|
||||||
|
}
|
||||||
|
type txPlayer interface {
|
||||||
|
PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
|
||||||
|
}
|
||||||
|
var player txPlayer
|
||||||
|
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||||
|
p, ok := t.(txPlayer)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("this radio cannot take transmit audio over its CAT link")
|
||||||
|
}
|
||||||
|
player = p
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return player.PlayTXAudio(pcm, rate, ch, bits, stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTCITXSource tells the radio where to take its transmit audio from,
|
||||||
|
// following the "To radio" device.
|
||||||
|
//
|
||||||
|
// This is the setting an operator would otherwise have to find in ExpertSDR3
|
||||||
|
// and set again for every mode, because it is remembered per mode there. It is
|
||||||
|
// the third argument of TRX, so OpsLog can simply say it each time it keys —
|
||||||
|
// and a radio told nothing keeps the operator's microphone, which is what
|
||||||
|
// every PTT that is not a voice message should do.
|
||||||
|
func (a *App) applyTCITXSource() {
|
||||||
|
if a.cat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, _ := a.GetAudioSettings()
|
||||||
|
src := ""
|
||||||
|
if cfg.ToRadio == audio.NetworkDeviceID {
|
||||||
|
src = "tci"
|
||||||
|
}
|
||||||
|
_ = a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||||
|
if s, ok := t.(interface{ SetTXAudioSource(string) }); ok {
|
||||||
|
s.SetTXAudioSource(src)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// installTCITXPlayer offers the radio as an audio output, or withdraws it.
|
||||||
|
//
|
||||||
|
// Withdrawing matters as much as offering: a radio that has gone away must stop
|
||||||
|
// being a device the voice keyer will happily "play" to, or a message goes
|
||||||
|
// nowhere and the operator hears their own PTT click and assumes it worked.
|
||||||
|
func (a *App) installTCITXPlayer(on bool) {
|
||||||
|
if !on {
|
||||||
|
audio.SetNetworkPlayer(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audio.SetNetworkPlayer(a.tciTXPlayer)
|
||||||
|
a.applyTCITXSource()
|
||||||
|
applog.Printf("tci: the radio is available as an audio output — no virtual cable needed for the voice keyer")
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The TCI control console — bindings.
|
||||||
|
//
|
||||||
|
// Thin on purpose: the state is a snapshot the radio pushed and the setters are
|
||||||
|
// one command each. Everything interesting is in internal/cat/tci_panel.go.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetTCIPanel returns the console state. Connected=false when the active CAT
|
||||||
|
// backend is not a TCI radio, so the frontend has one thing to look at rather
|
||||||
|
// than an error to distinguish from a disconnected radio.
|
||||||
|
func (a *App) GetTCIPanel() cat.TCIPanelState {
|
||||||
|
if a.cat == nil {
|
||||||
|
return cat.TCIPanelState{}
|
||||||
|
}
|
||||||
|
st, _ := a.cat.TCIPanelState()
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// tciDo is the shape every setter below takes.
|
||||||
|
func (a *App) tciDo(fn func(cat.TCIPanelController) error) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("CAT not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.TCIPanelDo(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTCIDrive sets the transmit drive (0-100).
|
||||||
|
func (a *App) SetTCIDrive(v int) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetDrive(v) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTCITuneDrive sets the drive TUNE uses (0-100).
|
||||||
|
func (a *App) SetTCITuneDrive(v int) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetTuneDrive(v) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTCIMicLevel sets the microphone gain (0-100).
|
||||||
|
func (a *App) SetTCIMicLevel(v int) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetMicLevel(v) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTCIVolume sets the receive volume in dB (0 down to -60).
|
||||||
|
func (a *App) SetTCIVolume(db int) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetVolume(db) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTCIMute mutes or unmutes the receiver.
|
||||||
|
func (a *App) SetTCIMute(on bool) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetMute(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTCIAGC picks the AGC speed: off, long, slow, med, fast.
|
||||||
|
func (a *App) SetTCIAGC(mode string) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetAGC(mode) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTCISquelch turns the squelch on or off.
|
||||||
|
func (a *App) SetTCISquelch(on bool) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetSquelch(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTCISquelchLevel sets the squelch threshold in dBm.
|
||||||
|
func (a *App) SetTCISquelchLevel(v int) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetSquelchLevel(v) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTCINB, SetTCINR, SetTCIANF and SetTCIAPF switch the receive processing.
|
||||||
|
func (a *App) SetTCINB(on bool) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetNB(on) })
|
||||||
|
}
|
||||||
|
func (a *App) SetTCINR(on bool) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetNR(on) })
|
||||||
|
}
|
||||||
|
func (a *App) SetTCIANF(on bool) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetANF(on) })
|
||||||
|
}
|
||||||
|
func (a *App) SetTCIAPF(on bool) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetAPF(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTCIFilter sets the passband edges in Hz.
|
||||||
|
func (a *App) SetTCIFilter(lo, hi int) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetFilter(lo, hi) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTCIRIT / SetTCIXIT switch the offsets on; the Offset calls move them.
|
||||||
|
func (a *App) SetTCIRIT(on bool) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetRIT(on) })
|
||||||
|
}
|
||||||
|
func (a *App) SetTCIXIT(on bool) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetXIT(on) })
|
||||||
|
}
|
||||||
|
func (a *App) SetTCIRITOffset(hz int) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetRITOffset(hz) })
|
||||||
|
}
|
||||||
|
func (a *App) SetTCIXITOffset(hz int) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetXITOffset(hz) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTCILock locks the radio's VFO knob.
|
||||||
|
func (a *App) SetTCILock(on bool) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetLock(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTCITune starts or stops the tune carrier.
|
||||||
|
//
|
||||||
|
// IT TRANSMITS, and at tune_drive rather than at drive — which is why the panel
|
||||||
|
// shows those two numbers next to the button rather than hiding one of them in
|
||||||
|
// a menu.
|
||||||
|
func (a *App) SetTCITune(on bool) error {
|
||||||
|
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetTune(on) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Feeding the QSO recorder from the TCI stream.
|
||||||
|
//
|
||||||
|
// The recorder works in 16 kHz mono, which is what its files and its mixing are
|
||||||
|
// built around; TCI delivers 48 kHz stereo float32. The conversion is the whole
|
||||||
|
// of this file, and it happens here rather than in internal/cat because the
|
||||||
|
// radio's job is to hand over what it sent, not to know what the recorder wants.
|
||||||
|
//
|
||||||
|
// Confirmed on a SunSDR (ExpertSDR3 1.5): 2048 samples a frame, 8192 bytes,
|
||||||
|
// four bytes per sample — and a test recording that plays back clean.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/audio"
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tciRecordSink pushes the receive stream into the QSO recorder.
|
||||||
|
//
|
||||||
|
// Installed whenever the TCI backend starts, and harmless when nothing is
|
||||||
|
// recording: PushRX drops what arrives unless a QSO is being captured, so the
|
||||||
|
// cost while idle is a decimation and a function call.
|
||||||
|
func (a *App) tciRecordSink(rate int, samples []float32) {
|
||||||
|
if a.qsoRec == nil || len(samples) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.qsoRec.PushRX(tciToRecorderPCM(rate, samples))
|
||||||
|
}
|
||||||
|
|
||||||
|
// tciToRecorderPCM converts the stream's mono float samples to the recorder's
|
||||||
|
// 16-bit PCM at its own rate.
|
||||||
|
//
|
||||||
|
// Averaging rather than picking every third sample: dropping samples aliases
|
||||||
|
// everything above 8 kHz back down into the voice band, and on a receiver that
|
||||||
|
// is hiss — the one thing a QSO recording has plenty of. A three-tap mean is a
|
||||||
|
// crude low-pass, but it is a low-pass, and it costs two additions.
|
||||||
|
func tciToRecorderPCM(rate int, samples []float32) []byte {
|
||||||
|
if rate <= 0 {
|
||||||
|
rate = 48000
|
||||||
|
}
|
||||||
|
step := rate / audio.RecorderSampleRate
|
||||||
|
if step < 1 {
|
||||||
|
step = 1
|
||||||
|
}
|
||||||
|
out := make([]byte, 0, (len(samples)/step)*2)
|
||||||
|
for i := 0; i+step <= len(samples); i += step {
|
||||||
|
var sum float32
|
||||||
|
for j := 0; j < step; j++ {
|
||||||
|
sum += samples[i+j]
|
||||||
|
}
|
||||||
|
v := sum / float32(step)
|
||||||
|
if v > 1 {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
if v < -1 {
|
||||||
|
v = -1
|
||||||
|
}
|
||||||
|
var b [2]byte
|
||||||
|
binary.LittleEndian.PutUint16(b[:], uint16(int16(v*32767)))
|
||||||
|
out = append(out, b[0], b[1])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTCIRecording opens the receive stream and routes it to the recorder.
|
||||||
|
//
|
||||||
|
// Called when the TCI backend comes up, and only when the operator has asked
|
||||||
|
// for it by choosing the radio as their receive device: opening a 384 kB/s
|
||||||
|
// stream on a station that records nothing is work the radio does for nobody.
|
||||||
|
func (a *App) startTCIRecording() {
|
||||||
|
if a.cat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// One switch, and it is the one an operator is already looking at: the
|
||||||
|
// "From radio" device. There used to be a tick box here as well, from when
|
||||||
|
// this was an experiment with no device to choose — two controls for one
|
||||||
|
// question, and the second was where nobody would look.
|
||||||
|
cfg, _ := a.GetAudioSettings()
|
||||||
|
if cfg.FromRadio != audio.NetworkDeviceID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||||
|
if s, ok := t.(interface {
|
||||||
|
SetTCIAudioSink(func(int, []float32))
|
||||||
|
}); ok {
|
||||||
|
s.SetTCIAudioSink(a.tciRecordSink)
|
||||||
|
}
|
||||||
|
return t.StartTCIAudio(0, 48000)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("tci: could not open the receive stream for recording: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("tci: recording the receive audio over TCI — no virtual cable needed")
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/audio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The stream is 48 kHz and the recorder works at 16 — three to one. A
|
||||||
|
// recording that keeps every sample plays back three times too fast, which is
|
||||||
|
// the fault that gets blamed on the decoding rather than on the rate.
|
||||||
|
func TestTheStreamIsResampledToTheRecorderRate(t *testing.T) {
|
||||||
|
const in = 48000
|
||||||
|
samples := make([]float32, in/10) // a tenth of a second
|
||||||
|
pcm := tciToRecorderPCM(in, samples)
|
||||||
|
want := (audio.RecorderSampleRate / 10) * 2 // 16-bit
|
||||||
|
if len(pcm) != want {
|
||||||
|
t.Fatalf("a tenth of a second produced %d bytes, want %d", len(pcm), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full scale must arrive as full scale: a conversion that quietly halves the
|
||||||
|
// level turns a recording into evidence of a fault that is not there.
|
||||||
|
func TestFullScaleSurvivesTheConversion(t *testing.T) {
|
||||||
|
samples := make([]float32, 12)
|
||||||
|
for i := range samples {
|
||||||
|
samples[i] = 1
|
||||||
|
}
|
||||||
|
pcm := tciToRecorderPCM(48000, samples)
|
||||||
|
if len(pcm) < 2 {
|
||||||
|
t.Fatal("no samples came out")
|
||||||
|
}
|
||||||
|
v := int16(binary.LittleEndian.Uint16(pcm[:2]))
|
||||||
|
if v < 32000 {
|
||||||
|
t.Fatalf("full scale came out at %d", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rate the recorder already works in is passed through rather than mangled by
|
||||||
|
// a division that would round to nothing.
|
||||||
|
func TestAStreamAtTheRecorderRateIsNotDecimated(t *testing.T) {
|
||||||
|
samples := make([]float32, 160)
|
||||||
|
if got, want := len(tciToRecorderPCM(audio.RecorderSampleRate, samples)), 160*2; got != want {
|
||||||
|
t.Fatalf("%d bytes, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Which decoder the entry field belongs to, when several are running.
|
||||||
|
//
|
||||||
|
// A station running WSJT-X, JTDX and MSHV at once has three programs sending
|
||||||
|
// Status once a second each. Click a call in one of them and only that one has
|
||||||
|
// a DX Call; the other two are idle and say so. Both statements are true, and
|
||||||
|
// both arrive — so the entry field is filled by the program the operator is
|
||||||
|
// working and emptied by the two that are not, once a second, and the map
|
||||||
|
// zooms in and out with it.
|
||||||
|
//
|
||||||
|
// So the first program to announce a station is FOCUSED, and until it lets go
|
||||||
|
// the others cannot touch the entry field. That is the operator's own answer:
|
||||||
|
// "if I call on one program, keep that one's UDP for the duration of the QSO".
|
||||||
|
//
|
||||||
|
// Focus is released when the focused program clears its own DX Call, when it
|
||||||
|
// stops sending altogether (it was closed), or when a QSO is logged — never on
|
||||||
|
// a timer that could hand the field to another program mid-over.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// udpFocusIdle is how long a focused program may go silent before the focus is
|
||||||
|
// given up.
|
||||||
|
//
|
||||||
|
// Generous on purpose: a decoder sends Status every second, so anything above a
|
||||||
|
// few seconds means it has been closed or has lost its network. Thirty is long
|
||||||
|
// enough to survive a machine that stutters and short enough that a program
|
||||||
|
// closed mid-QSO does not lock the entry field for the rest of the evening.
|
||||||
|
const udpFocusIdle = 30 * time.Second
|
||||||
|
|
||||||
|
type udpFocus struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
inst string
|
||||||
|
at time.Time
|
||||||
|
// told marks that the log already carries the line explaining why another
|
||||||
|
// program's callsign is being ignored. Once per focus, not once a second.
|
||||||
|
told map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// claim records that inst is announcing a station, and reports whether inst is
|
||||||
|
// the program the entry field currently belongs to.
|
||||||
|
func (f *udpFocus) claim(inst string) bool {
|
||||||
|
if inst == "" {
|
||||||
|
return true // a sender with no id: nothing to arbitrate between
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.inst == "" || f.inst == inst || time.Since(f.at) > udpFocusIdle {
|
||||||
|
if f.inst != inst {
|
||||||
|
applog.Printf("udp: the entry field follows %s while it is calling", inst)
|
||||||
|
f.told = nil
|
||||||
|
}
|
||||||
|
f.inst, f.at = inst, time.Now()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// holds reports whether inst may act on the entry field, without claiming it.
|
||||||
|
// Used for the clear: a program that is not focused clearing its own DX Call
|
||||||
|
// says nothing about the QSO in progress somewhere else.
|
||||||
|
func (f *udpFocus) holds(inst string) bool {
|
||||||
|
if inst == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.inst == "" || time.Since(f.at) > udpFocusIdle {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return f.inst == inst
|
||||||
|
}
|
||||||
|
|
||||||
|
// release gives the field up — the focused program cleared its call, or a QSO
|
||||||
|
// was logged and the next station may come from anywhere.
|
||||||
|
func (f *udpFocus) release(why string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
had := f.inst
|
||||||
|
f.inst, f.at, f.told = "", time.Time{}, nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if had != "" {
|
||||||
|
applog.Printf("udp: the entry field is free again (%s let go: %s)", had, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteIgnored logs, once per focused program, that another one's callsign was
|
||||||
|
// not applied. Without it the behaviour is invisible: an operator whose second
|
||||||
|
// decoder "stopped filling the call" has nothing to read.
|
||||||
|
func (f *udpFocus) noteIgnored(inst, call string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.told == nil {
|
||||||
|
f.told = map[string]bool{}
|
||||||
|
}
|
||||||
|
first := !f.told[inst]
|
||||||
|
f.told[inst] = true
|
||||||
|
holder := f.inst
|
||||||
|
f.mu.Unlock()
|
||||||
|
if first {
|
||||||
|
applog.Printf("udp: [%s] %q not applied — %s has the entry field while it is calling",
|
||||||
|
inst, strings.ToUpper(call), holder)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The reported failure, in order: MSHV is called on, WSJT-X and JTDX sit idle
|
||||||
|
// beside it, and every one of their Status packets used to empty the entry
|
||||||
|
// field that MSHV had just filled — once a second, with the map zooming in and
|
||||||
|
// out to match.
|
||||||
|
func TestUdpFocusKeepsTheFieldWithTheCallingProgram(t *testing.T) {
|
||||||
|
var f udpFocus
|
||||||
|
|
||||||
|
if !f.claim("MSHV") {
|
||||||
|
t.Fatal("the first program to announce a station must take the field")
|
||||||
|
}
|
||||||
|
// The other two, announcing stations of their own, are refused.
|
||||||
|
if f.claim("WSJT-X") {
|
||||||
|
t.Error("WSJT-X took the field while MSHV was calling")
|
||||||
|
}
|
||||||
|
if f.claim("JTDX") {
|
||||||
|
t.Error("JTDX took the field while MSHV was calling")
|
||||||
|
}
|
||||||
|
// And their clears do not empty it — this is the half that caused the flicker.
|
||||||
|
if f.holds("WSJT-X") {
|
||||||
|
t.Error("an idle WSJT-X was allowed to clear MSHV's callsign")
|
||||||
|
}
|
||||||
|
if !f.holds("MSHV") {
|
||||||
|
t.Error("MSHV lost the right to clear its own callsign")
|
||||||
|
}
|
||||||
|
// MSHV moving to the next station keeps the field.
|
||||||
|
if !f.claim("MSHV") {
|
||||||
|
t.Error("the focused program must keep the field across stations")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letting go, three ways.
|
||||||
|
func TestUdpFocusRelease(t *testing.T) {
|
||||||
|
var f udpFocus
|
||||||
|
|
||||||
|
// The focused program clears its own call.
|
||||||
|
f.claim("MSHV")
|
||||||
|
f.release("DX Call cleared")
|
||||||
|
if !f.claim("WSJT-X") {
|
||||||
|
t.Error("after a release the next program should be able to take the field")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A QSO is logged.
|
||||||
|
f.release("QSO logged")
|
||||||
|
if !f.claim("JTDX") {
|
||||||
|
t.Error("logging a QSO must free the field for whichever program hears the next station")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The focused program is closed and stops sending. Its hold lapses rather
|
||||||
|
// than locking the entry field for the rest of the evening.
|
||||||
|
f.mu.Lock()
|
||||||
|
f.at = time.Now().Add(-udpFocusIdle - time.Second)
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !f.claim("MSHV") {
|
||||||
|
t.Error("a silent program must not hold the field for ever")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sender with no program id — an ADIF relay, a remote "set call" — is not
|
||||||
|
// something to arbitrate between, and must never be locked out.
|
||||||
|
func TestUdpFocusIgnoresUnnamedSenders(t *testing.T) {
|
||||||
|
var f udpFocus
|
||||||
|
f.claim("MSHV")
|
||||||
|
if !f.claim("") {
|
||||||
|
t.Error("an unnamed sender was refused the entry field")
|
||||||
|
}
|
||||||
|
if !f.holds("") {
|
||||||
|
t.Error("an unnamed sender was refused a clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Watchlist bindings — the DXHunter watchlist concept as an OpsLog tab.
|
||||||
|
// The store lives in internal/watchlist (global watchlist.json, DXHunter's own
|
||||||
|
// schema); this file is the Wails boundary plus the two places the list meets
|
||||||
|
// the rest of the app: the spot stream (MarkSeen + alert) and the logbook (the
|
||||||
|
// worked-today answer contest entries are judged by).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
"hamlog/internal/watchlist"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The auto-contest pattern, DXHunter's contest_prefix: while non-empty, any
|
||||||
|
// spotted callsign CONTAINING it is added to the watchlist as a contest entry
|
||||||
|
// by itself — a special-event fleet (HB9WWA, DL0WWA, F4WWA…) is collected as
|
||||||
|
// it appears instead of typed in one by one. Held in an atomic so the spot
|
||||||
|
// pipeline never touches the settings store per spot.
|
||||||
|
func (a *App) GetWatchlistContestPattern() string {
|
||||||
|
if v := a.watchPattern.Load(); v != nil {
|
||||||
|
return v.(string)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWatchlistContestPattern stores the pattern (global, like the list itself).
|
||||||
|
// Emptying it stops the auto-add; entries already collected stay — deleting an
|
||||||
|
// operator's list because a setting changed is DXHunter behaviour this port
|
||||||
|
// deliberately drops.
|
||||||
|
func (a *App) SetWatchlistContestPattern(p string) {
|
||||||
|
p = strings.ToUpper(strings.TrimSpace(p))
|
||||||
|
a.watchPattern.Store(p)
|
||||||
|
a.setSettingGlobal(keyWatchlistContestPattern, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The named contest callsigns: the other half of the auto-add, and the half the
|
||||||
|
// pattern cannot express.
|
||||||
|
//
|
||||||
|
// A special-event fleet is usually a common string in the callsign — WWA in
|
||||||
|
// TM29WWA, HB9WWA, F4WWA/P — and the pattern collects those on sight. But an
|
||||||
|
// entry list is not a naming convention: R7W can be part of the same event and
|
||||||
|
// share nothing with it, and no pattern will ever catch that station without
|
||||||
|
// catching half the band with it. So the two work together: the pattern for the
|
||||||
|
// family, this list for everybody else.
|
||||||
|
//
|
||||||
|
// Stored GLOBALLY, like the pattern and the list itself — an event is worth
|
||||||
|
// hunting whichever station profile is on.
|
||||||
|
func (a *App) GetWatchlistContestCalls() string {
|
||||||
|
return a.settingOr(keyWatchlistContestCalls, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWatchlistContestCalls stores the list as typed and caches the parsed set.
|
||||||
|
//
|
||||||
|
// The RAW text is what is stored: the operator's line breaks and their order
|
||||||
|
// are how the list is read back a week later, and rewriting it into a
|
||||||
|
// normalised single line loses the only structure it has.
|
||||||
|
func (a *App) SetWatchlistContestCalls(list string) {
|
||||||
|
a.setSettingGlobal(keyWatchlistContestCalls, list)
|
||||||
|
a.watchCalls.Store(parseContestCalls(list))
|
||||||
|
applog.Printf("watchlist: %d named contest callsigns", len(parseContestCalls(list)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseContestCalls splits the box into a set. One per line is what is asked
|
||||||
|
// for, and commas, semicolons and spaces are accepted too: a list pasted from
|
||||||
|
// an announcement arrives in whatever shape the announcement used.
|
||||||
|
func parseContestCalls(list string) map[string]struct{} {
|
||||||
|
out := map[string]struct{}{}
|
||||||
|
for _, tok := range strings.FieldsFunc(strings.ToUpper(list), func(r rune) bool {
|
||||||
|
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||||
|
}) {
|
||||||
|
if tok = strings.TrimSpace(tok); tok != "" {
|
||||||
|
out[tok] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNamedContestCall answers the hot path from the cached set.
|
||||||
|
func (a *App) isNamedContestCall(call string) bool {
|
||||||
|
v := a.watchCalls.Load()
|
||||||
|
if v == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
set, ok := v.(map[string]struct{})
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, found := set[strings.ToUpper(strings.TrimSpace(call))]
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchlistEntries returns the list for the tab.
|
||||||
|
func (a *App) WatchlistEntries() []watchlist.Entry {
|
||||||
|
if a.watchlist == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.watchlist.Entries()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchlistAdd adds a callsign or prefix; contest entries are judged per UTC day.
|
||||||
|
func (a *App) WatchlistAdd(callsign string, contest bool) error {
|
||||||
|
if a.watchlist == nil {
|
||||||
|
return fmt.Errorf("watchlist not initialized")
|
||||||
|
}
|
||||||
|
if err := a.watchlist.Add(callsign, contest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.notifyWatchlist(callsign, true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchlistRemove deletes an entry.
|
||||||
|
func (a *App) WatchlistRemove(callsign string) error {
|
||||||
|
if a.watchlist == nil {
|
||||||
|
return fmt.Errorf("watchlist not initialized")
|
||||||
|
}
|
||||||
|
if err := a.watchlist.Remove(callsign); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.notifyWatchlist(callsign, false)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyWatchlist announces a membership change to the UI.
|
||||||
|
//
|
||||||
|
// Emitted from the BINDINGS rather than from the watchlist panel, because a
|
||||||
|
// call can now be added from three places (the panel, the cluster's menu, the
|
||||||
|
// DXpeditions tab) and an operator who added one from the cluster saw nothing
|
||||||
|
// at all — the confirmation lived inside a panel they were not looking at.
|
||||||
|
func (a *App) notifyWatchlist(callsign string, added bool) {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "watchlist:changed", map[string]any{
|
||||||
|
"call": strings.ToUpper(strings.TrimSpace(callsign)),
|
||||||
|
"added": added,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
|
||||||
|
func (a *App) WatchlistSetNotify(callsign string, on bool) error {
|
||||||
|
if a.watchlist == nil {
|
||||||
|
return fmt.Errorf("watchlist not initialized")
|
||||||
|
}
|
||||||
|
return a.watchlist.SetNotify(callsign, on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchlistSetContest flips the per-entry contest rule.
|
||||||
|
func (a *App) WatchlistSetContest(callsign string, on bool) error {
|
||||||
|
if a.watchlist == nil {
|
||||||
|
return fmt.Errorf("watchlist not initialized")
|
||||||
|
}
|
||||||
|
return a.watchlist.SetContest(callsign, on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchlistSlotQuery asks whether one spot's slot is worked — against the whole
|
||||||
|
// log for a normal entry, against TODAY (UTC) for a contest one.
|
||||||
|
type WatchlistSlotQuery struct {
|
||||||
|
Call string `json:"call"`
|
||||||
|
Band string `json:"band"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Contest bool `json:"contest"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchlistWorkedSlots answers a batch of slot questions for the tab.
|
||||||
|
//
|
||||||
|
// Normal entries read the CLUSTER's own slot set — the same cached
|
||||||
|
// WorkedCallSlotKeys index that colours the grid — so the watchlist can never
|
||||||
|
// contradict the cluster about the same spot. That index normalises the mode
|
||||||
|
// exactly as the operator configured (digital grouping on → FT4 counts as FT8's
|
||||||
|
// class; off → exact mode), and the first version of this ignored that: it
|
||||||
|
// asked the alerts' raw-mode index with a CLASS name, matched nothing, and
|
||||||
|
// showed a fully-worked DXpedition as all Needed.
|
||||||
|
//
|
||||||
|
// Contest entries read TODAY's contacts instead — the midnight-UTC reset is
|
||||||
|
// the query's date bound, nothing stored, nothing to reset — normalised through
|
||||||
|
// the same function so the two answers use one grammar.
|
||||||
|
func (a *App) WatchlistWorkedSlots(queries []WatchlistSlotQuery) []bool {
|
||||||
|
out := make([]bool, len(queries))
|
||||||
|
if a.qso == nil || len(queries) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
idx := a.clusterStatusMaps()
|
||||||
|
norm := func(m string) string {
|
||||||
|
m = strings.ToUpper(strings.TrimSpace(m))
|
||||||
|
if idx.normMode != nil {
|
||||||
|
m = idx.normMode(m)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
slotKey := func(call, band, mode string) string {
|
||||||
|
up := strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
b := strings.ToLower(strings.TrimSpace(band))
|
||||||
|
if m := norm(mode); m != "" {
|
||||||
|
return up + "|" + b + "|" + m
|
||||||
|
}
|
||||||
|
return up + "|" + b
|
||||||
|
}
|
||||||
|
// A spot whose mode the band plan could only call "DATA" (an F/H DXpedition
|
||||||
|
// off the standard dials, a comment with no mode) cannot be matched exactly:
|
||||||
|
// "DATA" is in nobody's log. Such a spot is judged at digital-CLASS grain —
|
||||||
|
// worked if ANY digital mode of that call is logged on that band. Claiming
|
||||||
|
// NEW MODE because the label differs from FT8 was the reported bug.
|
||||||
|
generic := func(m string) bool {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(m)) {
|
||||||
|
case "", "DATA", "DIG", "DIGI", "DIGITAL":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
digKey := func(call, band string) string {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(call)) + "|" + strings.ToLower(strings.TrimSpace(band)) + "|DIG"
|
||||||
|
}
|
||||||
|
needToday := false
|
||||||
|
for _, q := range queries {
|
||||||
|
if q.Contest {
|
||||||
|
needToday = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
today := map[string]bool{}
|
||||||
|
if needToday {
|
||||||
|
midnight := time.Now().UTC().Truncate(24 * time.Hour)
|
||||||
|
rows, err := a.qso.SlotsSince(a.ctx, midnight)
|
||||||
|
if err == nil {
|
||||||
|
for _, r := range rows {
|
||||||
|
today[slotKey(r.Callsign, r.Band, r.Mode)] = true
|
||||||
|
today[digKey(r.Callsign, r.Band)] = qso.ModeClass(r.Mode) == "DIG" || today[digKey(r.Callsign, r.Band)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, q := range queries {
|
||||||
|
if q.Contest {
|
||||||
|
if generic(q.Mode) {
|
||||||
|
out[i] = today[digKey(q.Call, q.Band)]
|
||||||
|
} else {
|
||||||
|
out[i] = today[slotKey(q.Call, q.Band, q.Mode)]
|
||||||
|
}
|
||||||
|
} else if generic(q.Mode) {
|
||||||
|
if idx.workedCallSlotsDig != nil {
|
||||||
|
_, out[i] = idx.workedCallSlotsDig[digKey(q.Call, q.Band)]
|
||||||
|
}
|
||||||
|
} else if idx.workedCallSlots != nil {
|
||||||
|
_, out[i] = idx.workedCallSlots[slotKey(q.Call, q.Band, q.Mode)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchSpot runs one live spot through the watchlist: last-seen bookkeeping and
|
||||||
|
// the alert, through the SAME event the alert rules fire — the frontend already
|
||||||
|
// knows how to toast and sound it, and a second notification path would be a
|
||||||
|
// second thing to misconfigure.
|
||||||
|
func (a *App) watchSpot(dxCall, band, mode, country, comment string, freqHz int64) {
|
||||||
|
if a.watchlist == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry, notify, ok := a.watchlist.MarkSeen(dxCall)
|
||||||
|
if !ok {
|
||||||
|
// Not watched yet — the contest pattern or the named list may claim it.
|
||||||
|
// Contains, not prefix, for the pattern: the event string sits anywhere
|
||||||
|
// in those calls (HB9WWA, F4WWA/P). The named list is exact, and is what
|
||||||
|
// catches the entries whose callsign says nothing about the event.
|
||||||
|
p := a.GetWatchlistContestPattern()
|
||||||
|
byPattern := p != "" && strings.Contains(strings.ToUpper(dxCall), p)
|
||||||
|
byName := a.isNamedContestCall(dxCall)
|
||||||
|
if byPattern || byName {
|
||||||
|
if err := a.watchlist.Add(dxCall, true); err == nil {
|
||||||
|
why := fmt.Sprintf("contest pattern %q", p)
|
||||||
|
if byName {
|
||||||
|
why = "named in the contest list"
|
||||||
|
}
|
||||||
|
applog.Printf("watchlist: auto-added %s (%s)", dxCall, why)
|
||||||
|
entry, notify, ok = a.watchlist.MarkSeen(dxCall)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "watchlist:changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok || !notify || a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Already worked? Then nothing to announce. Judged HERE, before the sound —
|
||||||
|
// the report was an alert ringing for a slot the tab showed as Worked a
|
||||||
|
// moment later, because the frontend's verdict arrives on a debounce while
|
||||||
|
// the alert used to fire on the raw spot. Same verdict as the tab: exact
|
||||||
|
// slot for named modes, digital class for a generic DATA spot, today-only
|
||||||
|
// for a contest entry.
|
||||||
|
if e, found := a.watchlist.Get(entry); found {
|
||||||
|
if a.WatchlistWorkedSlots([]WatchlistSlotQuery{{Call: dxCall, Band: band, Mode: mode, Contest: e.IsContest}})[0] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Throttled per entry: a DXpedition lights up every skimmer on the planet,
|
||||||
|
// and forty alerts a minute for one station is a alarm nobody keeps on.
|
||||||
|
a.watchAlertMu.Lock()
|
||||||
|
last := a.watchAlertAt[entry]
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(last) < 2*time.Minute {
|
||||||
|
a.watchAlertMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.watchAlertAt[entry] = now
|
||||||
|
a.watchAlertMu.Unlock()
|
||||||
|
wruntime.EventsEmit(a.ctx, "alert:fired", map[string]any{
|
||||||
|
"rule": "Watchlist " + entry,
|
||||||
|
"call": strings.ToUpper(strings.TrimSpace(dxCall)),
|
||||||
|
"band": band,
|
||||||
|
"mode": mode,
|
||||||
|
"freq_hz": freqHz,
|
||||||
|
"country": country,
|
||||||
|
"comment": comment,
|
||||||
|
"sound": true,
|
||||||
|
"visual": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWatchlistClubLog begins the expedition enrichment: every entry's
|
||||||
|
// ClubLog block (expedition flag, OQRS, LiveStream, QSO totals and 24 h rate)
|
||||||
|
// is refreshed on a rolling schedule — hourly for expeditions, six-hourly for
|
||||||
|
// the rest, DXHunter's own cadence. OpsLog's application API key is used, so
|
||||||
|
// there is nothing to configure.
|
||||||
|
//
|
||||||
|
// Two at a time with a breath between requests: the key is shared by every
|
||||||
|
// OpsLog install, and a hundred-entry watchlist must read as a trickle at
|
||||||
|
// ClubLog's end, not a burst.
|
||||||
|
func (a *App) startWatchlistClubLog() {
|
||||||
|
if a.watchlist == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
// Let the app finish starting before the first network pass.
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
for {
|
||||||
|
stale := a.watchlist.StaleEntries(1*time.Hour, 6*time.Hour)
|
||||||
|
changed := false
|
||||||
|
for _, call := range stale {
|
||||||
|
// The pattern's BASE is what ClubLog knows: VK9* has no log.
|
||||||
|
base := strings.TrimSuffix(call, "*")
|
||||||
|
if base == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
d, err := watchlist.FetchWatch(ctx, client, clublogAppAPIKey, base)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("watchlist clublog: %s: %v", base, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.watchlist.ApplyClubLog(call, d) {
|
||||||
|
changed = true
|
||||||
|
if d.IsExpedition {
|
||||||
|
applog.Printf("watchlist clublog: %s is a DXpedition (%d QSOs, %d/24h, OQRS=%v)",
|
||||||
|
base, func() int {
|
||||||
|
if d.ClubLogInfo != nil {
|
||||||
|
return d.ClubLogInfo.TotalQSOs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}(), d.QSOs24h(), d.HasOQRS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if changed && a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "watchlist:changed")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Minute)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Log-aware colours in WSJT-X / JTDX's own Band Activity window (message 13),
|
||||||
|
// the way JTAlert paints them: a decode of a watchlist member, a new DXCC or a
|
||||||
|
// new band for its entity is highlighted where the operator is actually
|
||||||
|
// looking. The verdicts come from the same cluster status cache that colours
|
||||||
|
// the spot grid, so the two windows can never disagree.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/dxcc"
|
||||||
|
udp "hamlog/internal/integrations/udp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyWsjtHighlight = "udp.wsjt.highlight"
|
||||||
|
keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode
|
||||||
|
keyWsjtHLWorked = "udp.wsjt.highlight_worked"
|
||||||
|
// One key per verdict, holding "#RRGGBB". Only the BACKGROUND is stored: the
|
||||||
|
// text colour is computed from it, so a chosen colour can never come out
|
||||||
|
// unreadable in somebody else's window.
|
||||||
|
keyWsjtColWatchlist = "udp.wsjt.colour.watchlist"
|
||||||
|
keyWsjtColNewDXCC = "udp.wsjt.colour.new_dxcc"
|
||||||
|
keyWsjtColNewBand = "udp.wsjt.colour.new_band"
|
||||||
|
keyWsjtColWorked = "udp.wsjt.colour.worked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wsjtModes are the modes a Configure message can meaningfully ask for — the
|
||||||
|
// decoder's own vocabulary. Anything else (CW, SSB, RTTY) is none of its
|
||||||
|
// business and is not sent.
|
||||||
|
var wsjtModes = map[string]bool{
|
||||||
|
"FT8": true, "FT4": true, "JT65": true, "JT9": true,
|
||||||
|
"MSK144": true, "Q65": true, "FST4": true, "JS8": false, // JS8Call speaks another protocol
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWsjtFollowMode reports whether spot clicks retune the decoder's mode.
|
||||||
|
func (a *App) GetWsjtFollowMode() bool {
|
||||||
|
return a.settingOr(keyWsjtFollowMode, "1") == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWsjtFollowMode flips it.
|
||||||
|
func (a *App) SetWsjtFollowMode(on bool) {
|
||||||
|
v := "0"
|
||||||
|
if on {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
a.setSetting(keyWsjtFollowMode, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureDecoderMode asks the connected decoders to switch mode — called by
|
||||||
|
// the frontend after a spot click has tuned the radio. A no-op for modes the
|
||||||
|
// decoder does not speak, and when the option is off or nothing is connected.
|
||||||
|
func (a *App) ConfigureDecoderMode(mode string) {
|
||||||
|
mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
if a.udp == nil || !wsjtModes[mode] || !a.GetWsjtFollowMode() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.udp.SendConfigureMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The DEFAULT palette. Fixed colours, not theme tokens — they are painted into
|
||||||
|
// another application's window, which has no idea what theme OpsLog wears, and
|
||||||
|
// the operator can change each of them (see WsjtHighlightColours).
|
||||||
|
var (
|
||||||
|
hlWatchlist = udp.RGB{R: 244, G: 114, B: 182} // the watchlist pink
|
||||||
|
hlNewDXCC = udp.RGB{R: 22, G: 130, B: 60} // green
|
||||||
|
hlNewBand = udp.RGB{R: 226, G: 122, B: 24} // orange
|
||||||
|
hlWhite = udp.RGB{R: 255, G: 255, B: 255}
|
||||||
|
hlBlack = udp.RGB{R: 20, G: 20, B: 20}
|
||||||
|
// Worked already, on this band and in this mode. Grey on purpose, and the
|
||||||
|
// only DIM colour of the four: the others say "look at this", and this one
|
||||||
|
// says the opposite — it has to recede, not compete with them.
|
||||||
|
hlWorked = udp.RGB{R: 75, G: 85, B: 99}
|
||||||
|
)
|
||||||
|
|
||||||
|
// WsjtHighlightColours is the operator's palette, one background per verdict.
|
||||||
|
type WsjtHighlightColours struct {
|
||||||
|
Watchlist string `json:"watchlist"`
|
||||||
|
NewDXCC string `json:"new_dxcc"`
|
||||||
|
NewBand string `json:"new_band"`
|
||||||
|
Worked string `json:"worked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWsjtHighlightColours returns the palette in "#RRGGBB", defaults included.
|
||||||
|
func (a *App) GetWsjtHighlightColours() WsjtHighlightColours {
|
||||||
|
return WsjtHighlightColours{
|
||||||
|
Watchlist: a.settingOr(keyWsjtColWatchlist, hexOfRGB(hlWatchlist)),
|
||||||
|
NewDXCC: a.settingOr(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC)),
|
||||||
|
NewBand: a.settingOr(keyWsjtColNewBand, hexOfRGB(hlNewBand)),
|
||||||
|
Worked: a.settingOr(keyWsjtColWorked, hexOfRGB(hlWorked)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWsjtHighlightColours stores the palette and repaints.
|
||||||
|
//
|
||||||
|
// The repaint is the whole point of clearing: the de-duplication remembers what
|
||||||
|
// it has already told each decoder, so without this a callsign keeps yesterday's
|
||||||
|
// colour until it changes verdict — and the operator, having just picked a new
|
||||||
|
// one, sees nothing happen.
|
||||||
|
func (a *App) SetWsjtHighlightColours(c WsjtHighlightColours) {
|
||||||
|
set := func(key, v, def string) {
|
||||||
|
if _, ok := parseHexRGB(v); !ok {
|
||||||
|
v = def
|
||||||
|
}
|
||||||
|
a.setSetting(key, strings.ToUpper(strings.TrimSpace(v)))
|
||||||
|
}
|
||||||
|
set(keyWsjtColWatchlist, c.Watchlist, hexOfRGB(hlWatchlist))
|
||||||
|
set(keyWsjtColNewDXCC, c.NewDXCC, hexOfRGB(hlNewDXCC))
|
||||||
|
set(keyWsjtColNewBand, c.NewBand, hexOfRGB(hlNewBand))
|
||||||
|
set(keyWsjtColWorked, c.Worked, hexOfRGB(hlWorked))
|
||||||
|
a.clearWsjtHighlights()
|
||||||
|
applog.Printf("wsjt highlight: palette changed — repainting")
|
||||||
|
}
|
||||||
|
|
||||||
|
// colourFor reads one verdict's background and picks a legible foreground.
|
||||||
|
//
|
||||||
|
// The text colour is DERIVED, never stored: an operator choosing a dark blue
|
||||||
|
// would otherwise get black text on it in somebody else's window and conclude
|
||||||
|
// the feature is broken. Rec. 601 luma, the same rule a browser's contrast
|
||||||
|
// checker uses, with the threshold where black stops being readable.
|
||||||
|
func (a *App) colourFor(key, def string) (udp.RGB, udp.RGB) {
|
||||||
|
bg, ok := parseHexRGB(a.settingOr(key, def))
|
||||||
|
if !ok {
|
||||||
|
bg, _ = parseHexRGB(def)
|
||||||
|
}
|
||||||
|
luma := (299*int(bg.R) + 587*int(bg.G) + 114*int(bg.B)) / 1000
|
||||||
|
if luma < 140 {
|
||||||
|
return bg, hlWhite
|
||||||
|
}
|
||||||
|
return bg, hlBlack
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexOfRGB(c udp.RGB) string { return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B) }
|
||||||
|
|
||||||
|
// parseHexRGB reads "#RRGGBB" (or "RRGGBB"). Anything else is refused rather
|
||||||
|
// than half-read: a colour that silently becomes black is worse than a default.
|
||||||
|
func parseHexRGB(s string) (udp.RGB, bool) {
|
||||||
|
s = strings.TrimPrefix(strings.TrimSpace(s), "#")
|
||||||
|
if len(s) != 6 {
|
||||||
|
return udp.RGB{}, false
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseUint(s, 16, 32)
|
||||||
|
if err != nil {
|
||||||
|
return udp.RGB{}, false
|
||||||
|
}
|
||||||
|
return udp.RGB{R: byte(v >> 16), G: byte(v >> 8), B: byte(v)}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWsjtHighlightWorked reports whether stations already worked on this band
|
||||||
|
// and mode are greyed out as well.
|
||||||
|
//
|
||||||
|
// Its own switch, and off by default. The other three verdicts pick out a
|
||||||
|
// handful of decodes in a period; this one can match most of them on a
|
||||||
|
// well-filled log, and a screen where nearly every line is coloured has stopped
|
||||||
|
// saying anything. It is worth having only for an operator who wants the dupes
|
||||||
|
// struck out rather than the catches picked out.
|
||||||
|
func (a *App) GetWsjtHighlightWorked() bool {
|
||||||
|
return a.settingOr(keyWsjtHLWorked, "0") == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWsjtHighlightWorked flips it, and repaints.
|
||||||
|
//
|
||||||
|
// Turning it OFF has to clear what it painted: those callsigns keep their grey
|
||||||
|
// in the decoder's window otherwise, and nothing would ever say another word
|
||||||
|
// about them — the de-duplication remembers that they were already told.
|
||||||
|
func (a *App) SetWsjtHighlightWorked(on bool) {
|
||||||
|
v := "0"
|
||||||
|
if on {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
a.setSetting(keyWsjtHLWorked, v)
|
||||||
|
a.wsjtHLWorkedOn.Store(on)
|
||||||
|
a.clearWsjtHighlights()
|
||||||
|
applog.Printf("wsjt highlight: worked stations %v", on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearWsjtHighlights wipes every instruction OpsLog installed, in every
|
||||||
|
// running decoder, and forgets what it had said.
|
||||||
|
func (a *App) clearWsjtHighlights() {
|
||||||
|
if a.udp == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, inst := range a.udp.Instances() {
|
||||||
|
_ = a.udp.SendClearHighlights(inst)
|
||||||
|
}
|
||||||
|
a.wsjtHLMu.Lock()
|
||||||
|
a.wsjtHLSent = map[string]string{}
|
||||||
|
a.wsjtHLMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWsjtHighlight reports whether decode highlighting is on.
|
||||||
|
func (a *App) GetWsjtHighlight() bool {
|
||||||
|
return a.settingOr(keyWsjtHighlight, "0") == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWsjtHighlight turns decode highlighting on or off. Turning it OFF also
|
||||||
|
// clears every instruction OpsLog installed in the running applications — a
|
||||||
|
// disabled option that leaves stale colours behind looks broken, not disabled.
|
||||||
|
func (a *App) SetWsjtHighlight(on bool) {
|
||||||
|
v := "0"
|
||||||
|
if on {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
a.setSetting(keyWsjtHighlight, v)
|
||||||
|
a.wsjtHighlightOn.Store(on)
|
||||||
|
if !on {
|
||||||
|
a.clearWsjtHighlights()
|
||||||
|
applog.Printf("wsjt highlight: off — cleared in every instance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeHighlightDecode paints one decoded callsign in the instance that heard
|
||||||
|
// it, when the option is on and the verdict is worth a colour. De-duplicated
|
||||||
|
// per instance+call+verdict: a station CQing all evening is decoded four times
|
||||||
|
// a minute, and the instruction only needs to be said once.
|
||||||
|
func (a *App) maybeHighlightDecode(instance, call, band, mode string) {
|
||||||
|
if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bg, fg, verdict := a.decodeHighlightVerdict(call, band, mode)
|
||||||
|
// The MODE is part of the key: one receiver can be handed FT8 and FT4 in
|
||||||
|
// the same session, and the verdict on a callsign is not the same in both.
|
||||||
|
key := instance + "|" + strings.ToUpper(call) + "|" + band + "|" + strings.ToUpper(mode)
|
||||||
|
a.wsjtHLMu.Lock()
|
||||||
|
if a.wsjtHLSent == nil {
|
||||||
|
a.wsjtHLSent = map[string]string{}
|
||||||
|
}
|
||||||
|
if len(a.wsjtHLSent) > 4000 { // bounded; a long session just re-says a few
|
||||||
|
a.wsjtHLSent = map[string]string{}
|
||||||
|
}
|
||||||
|
prev, had := a.wsjtHLSent[key]
|
||||||
|
if had && prev == verdict {
|
||||||
|
a.wsjtHLMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.wsjtHLSent[key] = verdict
|
||||||
|
a.wsjtHLMu.Unlock()
|
||||||
|
if verdict == "" {
|
||||||
|
// Was highlighted under an earlier verdict and no longer deserves it
|
||||||
|
// (the operator just worked them): clear that one callsign.
|
||||||
|
if had && prev != "" {
|
||||||
|
_ = a.udp.SendHighlight(instance, call, nil, nil, false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = a.udp.SendHighlight(instance, call, bg, fg, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeHighlightVerdict ranks a callsign: watchlist beats new-DXCC beats
|
||||||
|
// new-band, and "already worked here" comes last of all — it is the only
|
||||||
|
// verdict that says do NOT call, so anything worth calling for outranks it.
|
||||||
|
// Anything else is "no colour", and the empty verdict doubles as the clear
|
||||||
|
// signal in maybeHighlightDecode.
|
||||||
|
func (a *App) decodeHighlightVerdict(call, band, mode string) (bg, fg *udp.RGB, verdict string) {
|
||||||
|
c := a.clusterStatusMaps()
|
||||||
|
// ALREADY WORKED HERE, whatever else the station is.
|
||||||
|
//
|
||||||
|
// Settled first because it is the one fact that cancels the others. A watch
|
||||||
|
// list entry worked on this band and mode stayed pink for the rest of the
|
||||||
|
// session — the list is a statement of intent, not of what is left to do, and
|
||||||
|
// the colour that means "call this one" was being shown for a station already
|
||||||
|
// in the log. From the operator's side there was no way to tell the two
|
||||||
|
// apart, which is the only thing the colours are for.
|
||||||
|
workedHere := false
|
||||||
|
if band != "" && mode != "" && c.workedCallSlots != nil {
|
||||||
|
m := strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
if c.normMode != nil {
|
||||||
|
m = c.normMode(m)
|
||||||
|
}
|
||||||
|
_, workedHere = c.workedCallSlots[strings.ToUpper(call)+"|"+strings.ToLower(band)+"|"+m]
|
||||||
|
}
|
||||||
|
if a.watchlist != nil && !workedHere {
|
||||||
|
if _, ok := a.watchlist.Match(call); ok {
|
||||||
|
bgc, fgc := a.colourFor(keyWsjtColWatchlist, hexOfRGB(hlWatchlist))
|
||||||
|
return &bgc, &fgc, "watchlist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.dxcc != nil {
|
||||||
|
if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
|
||||||
|
num := dxcc.EntityDXCC(m.Entity.Name)
|
||||||
|
ent := c.entities[num]
|
||||||
|
if ent == nil {
|
||||||
|
bgc, fgc := a.colourFor(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC))
|
||||||
|
return &bgc, &fgc, "new-dxcc"
|
||||||
|
}
|
||||||
|
if band != "" {
|
||||||
|
if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand {
|
||||||
|
bgc, fgc := a.colourFor(keyWsjtColNewBand, hexOfRGB(hlNewBand))
|
||||||
|
return &bgc, &fgc, "new-band"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A dupe, judged by the same ledger and the same digital-mode grouping the
|
||||||
|
// cluster uses, so the two windows cannot disagree about what "worked" means.
|
||||||
|
// Its own option: on a well-filled log this matches most of a period, and a
|
||||||
|
// screen where nearly every line is coloured has stopped saying anything.
|
||||||
|
if workedHere && a.wsjtHLWorkedOn.Load() {
|
||||||
|
bgc, fgc := a.colourFor(keyWsjtColWorked, hexOfRGB(hlWorked))
|
||||||
|
return &bgc, &fgc, "worked"
|
||||||
|
}
|
||||||
|
return nil, nil, ""
|
||||||
|
}
|
||||||
+247
@@ -0,0 +1,247 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Row colouring for the log grid, by QSL / LoTW status — the thing Logger32 does
|
||||||
|
// and the reason an operator can tell at a glance what still needs sending.
|
||||||
|
//
|
||||||
|
// Rules are ORDERED and the first match wins, because a contact is usually
|
||||||
|
// several things at once: one confirmed on LoTW and by card is confirmed, not
|
||||||
|
// "sent, awaiting reply". Putting the order in the data rather than in a chain
|
||||||
|
// of ifs is what lets the settings panel show it in the same order it applies.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RowColorRule is one status and the colour it paints.
|
||||||
|
type RowColorRule struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Channels this rule looks at: qsl (paper), lotw, eqsl, qrz, hamlog.
|
||||||
|
//
|
||||||
|
// Empty means ALL of them — what an operator expects from a rule they have
|
||||||
|
// not narrowed, and what keeps a config written before this field meaningful.
|
||||||
|
Channels []string `json:"channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The QSL channels a rule can be scoped to.
|
||||||
|
var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz", "hamlog"}
|
||||||
|
|
||||||
|
// RowColorSettings is the whole appearance block.
|
||||||
|
type RowColorSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Style is how the colour is shown: "bar" paints a stripe down the left edge,
|
||||||
|
// "tint" washes the row, "both" does each.
|
||||||
|
//
|
||||||
|
// A bar is the default because a filled row is a poor signal in a log where
|
||||||
|
// nearly every contact has SOME QSL state: colour that is always present
|
||||||
|
// stops being information and becomes a striped background, with the data
|
||||||
|
// behind it. The stripe says the same thing and costs nothing to read.
|
||||||
|
Style string `json:"style"`
|
||||||
|
// Intensity is the tint strength in percent. Only used by "tint"/"both".
|
||||||
|
Intensity int `json:"intensity"`
|
||||||
|
// BandMapLotw marks stations that upload to LoTW on the band map, with the
|
||||||
|
// same "L" badge the cluster list uses — one visual vocabulary across the two
|
||||||
|
// views rather than a second invention.
|
||||||
|
BandMapLotw bool `json:"bandmap_lotw"`
|
||||||
|
// Configured distinguishes "saved with this off" from "saved before the
|
||||||
|
// option existed", so a new marker can default ON without silently turning
|
||||||
|
// itself back on for an operator who switched it off.
|
||||||
|
// RecentZebra decides whether the Recent QSOs grid alternates its row
|
||||||
|
// background. "" (or "on") keeps the banding, which is what the grid has
|
||||||
|
// always done; "off" makes every row the same colour.
|
||||||
|
//
|
||||||
|
// A string with an empty default rather than a bool: a bool would have to
|
||||||
|
// mean "true when absent", and every reader would have to remember that.
|
||||||
|
RecentZebra string `json:"recent_zebra,omitempty"`
|
||||||
|
// RecentZebraColor is the alternate row's colour. Empty follows the theme,
|
||||||
|
// which is the only answer that stays right across all four of them.
|
||||||
|
RecentZebraColor string `json:"recent_zebra_color,omitempty"`
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
Rules []RowColorRule `json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rule ids, in priority order. The frontend matches on these and holds the
|
||||||
|
// labels, so a translated name never has to travel through the settings.
|
||||||
|
// The four categories, in the order they are tested — first match wins.
|
||||||
|
//
|
||||||
|
// "To send" comes FIRST on purpose. A contact can be confirmed on LoTW and
|
||||||
|
// still owe a paper card, and the colour an operator scans for is the one that
|
||||||
|
// means "there is something left to do here". Put after "confirmed", that row
|
||||||
|
// would go green and the card would never be printed.
|
||||||
|
//
|
||||||
|
// "Worked" is the catch-all: a contact with nothing sent, nothing asked for and
|
||||||
|
// nothing back. Off by default — with it on, every remaining row is painted.
|
||||||
|
var rowColorOrder = []string{
|
||||||
|
"to_send", // requested or queued on a watched channel, not gone out
|
||||||
|
"confirmed", // a watched channel has a confirmation back
|
||||||
|
"sent", // gone out on a watched channel, nothing back yet
|
||||||
|
"worked", // none of the above
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults: green for done, amber for waiting, blue for owed. Deliberately
|
||||||
|
// muted — they are composited at low opacity over a dark grid, and a saturated
|
||||||
|
// value there reads as an error state rather than a status.
|
||||||
|
var rowColorDefaults = map[string]string{
|
||||||
|
"to_send": "#a855f7",
|
||||||
|
"confirmed": "#16a34a",
|
||||||
|
"sent": "#f59e0b",
|
||||||
|
"worked": "#64748b",
|
||||||
|
}
|
||||||
|
|
||||||
|
// hexColor guards what reaches the stylesheet. The value is interpolated into a
|
||||||
|
// CSS color-mix() by the grid, so anything that is not plainly a hex colour is
|
||||||
|
// refused rather than passed through.
|
||||||
|
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||||
|
|
||||||
|
func normRowColors(s RowColorSettings) RowColorSettings {
|
||||||
|
byID := map[string]RowColorRule{}
|
||||||
|
for _, r := range s.Rules {
|
||||||
|
byID[r.ID] = r
|
||||||
|
}
|
||||||
|
out := RowColorSettings{
|
||||||
|
Enabled: s.Enabled, Style: s.Style, Intensity: s.Intensity,
|
||||||
|
BandMapLotw: s.BandMapLotw, Configured: true,
|
||||||
|
RecentZebra: strings.ToLower(strings.TrimSpace(s.RecentZebra)),
|
||||||
|
}
|
||||||
|
if out.RecentZebra != "off" {
|
||||||
|
out.RecentZebra = "" // anything unrecognised means the default banding
|
||||||
|
}
|
||||||
|
if c := strings.TrimSpace(s.RecentZebraColor); hexColor.MatchString(c) {
|
||||||
|
out.RecentZebraColor = c
|
||||||
|
}
|
||||||
|
if !s.Configured {
|
||||||
|
out.BandMapLotw = true // new option, on unless the operator says otherwise
|
||||||
|
}
|
||||||
|
switch out.Style {
|
||||||
|
case "bar", "tint", "both":
|
||||||
|
default:
|
||||||
|
out.Style = "bar"
|
||||||
|
}
|
||||||
|
// Clamped rather than rejected: the value only shapes a colour-mix, and a
|
||||||
|
// number outside the range is a slider that got away, not a fault worth
|
||||||
|
// resetting the operator's whole choice for.
|
||||||
|
if out.Intensity < 5 {
|
||||||
|
out.Intensity = 12
|
||||||
|
} else if out.Intensity > 45 {
|
||||||
|
out.Intensity = 45
|
||||||
|
}
|
||||||
|
ok := map[string]bool{}
|
||||||
|
for _, c := range rowColorChannels {
|
||||||
|
ok[c] = true
|
||||||
|
}
|
||||||
|
for _, id := range rowColorOrder {
|
||||||
|
r := byID[id]
|
||||||
|
r.ID = id
|
||||||
|
if !hexColor.MatchString(strings.TrimSpace(r.Color)) {
|
||||||
|
r.Color = rowColorDefaults[id]
|
||||||
|
}
|
||||||
|
// Keep only channels we know, in the canonical order, de-duplicated.
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, c := range r.Channels {
|
||||||
|
want[strings.ToLower(strings.TrimSpace(c))] = true
|
||||||
|
}
|
||||||
|
chans := []string{}
|
||||||
|
for _, c := range rowColorChannels {
|
||||||
|
if want[c] {
|
||||||
|
chans = append(chans, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.Channels = chans
|
||||||
|
out.Rules = append(out.Rules, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatrixColors recolours the band/mode matrix — the PH/CW/DIG grid in the Stats
|
||||||
|
// panel, whose five fills and current-entry ring are the fastest read in the
|
||||||
|
// whole app and the one an operator is most likely to want in their own colours.
|
||||||
|
//
|
||||||
|
// Every colour is OPTIONAL and an empty one keeps whatever the active theme
|
||||||
|
// paints. That is why this stores OVERRIDES rather than a palette: each of the
|
||||||
|
// twelve themes ships a matrix ramp tuned to its own background, and an operator
|
||||||
|
// who only wants a different green must not thereby freeze the other four to the
|
||||||
|
// theme they happened to be using the day they picked it.
|
||||||
|
type MatrixColors struct {
|
||||||
|
// Enabled off leaves the theme's own ramp untouched, so switching it off is a
|
||||||
|
// genuine revert and not "some other set of colours".
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CallConfirmed string `json:"call_confirmed"`
|
||||||
|
CallWorked string `json:"call_worked"`
|
||||||
|
EntityConfirmed string `json:"entity_confirmed"`
|
||||||
|
EntityWorked string `json:"entity_worked"`
|
||||||
|
NotWorked string `json:"not_worked"`
|
||||||
|
CurrentEntry string `json:"current_entry"`
|
||||||
|
// The dot marking a slot already worked with the callsign in hand. It is a
|
||||||
|
// mark rather than a background, so it needs its own two colours: it is
|
||||||
|
// drawn on top of all five of the above and must stay legible over each.
|
||||||
|
MarkWorked string `json:"mark_worked"`
|
||||||
|
MarkConfirmed string `json:"mark_confirmed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e.
|
||||||
|
// "use the theme's" — because these are written straight into a CSS custom
|
||||||
|
// property, and the same rule as hexColor's own comment applies: what cannot be
|
||||||
|
// trusted into a stylesheet is refused rather than passed through.
|
||||||
|
func normMatrixColors(c MatrixColors) MatrixColors {
|
||||||
|
clean := func(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if hexColor.MatchString(s) {
|
||||||
|
return strings.ToLower(s)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return MatrixColors{
|
||||||
|
Enabled: c.Enabled,
|
||||||
|
CallConfirmed: clean(c.CallConfirmed),
|
||||||
|
CallWorked: clean(c.CallWorked),
|
||||||
|
EntityConfirmed: clean(c.EntityConfirmed),
|
||||||
|
EntityWorked: clean(c.EntityWorked),
|
||||||
|
NotWorked: clean(c.NotWorked),
|
||||||
|
CurrentEntry: clean(c.CurrentEntry),
|
||||||
|
MarkWorked: clean(c.MarkWorked),
|
||||||
|
MarkConfirmed: clean(c.MarkConfirmed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMatrixColors returns the operator's matrix palette overrides. All-empty
|
||||||
|
// (the default) means "whatever the theme says".
|
||||||
|
func (a *App) GetMatrixColors() MatrixColors {
|
||||||
|
var c MatrixColors
|
||||||
|
if raw := a.settingOr(keyMatrixColors, ""); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &c)
|
||||||
|
}
|
||||||
|
return normMatrixColors(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveMatrixColors persists them.
|
||||||
|
func (a *App) SaveMatrixColors(c MatrixColors) error {
|
||||||
|
b, err := json.Marshal(normMatrixColors(c))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setSetting(keyMatrixColors, string(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRowColors returns the row-colouring configuration, defaults included so the
|
||||||
|
// panel never has to invent one.
|
||||||
|
func (a *App) GetRowColors() RowColorSettings {
|
||||||
|
var s RowColorSettings
|
||||||
|
if raw := a.settingOr(keyRowColors, ""); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &s)
|
||||||
|
}
|
||||||
|
return normRowColors(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRowColors persists it.
|
||||||
|
func (a *App) SaveRowColors(s RowColorSettings) error {
|
||||||
|
b, err := json.Marshal(normRowColors(s))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setSetting(keyRowColors, string(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The colour is interpolated into a CSS color-mix() by the grid, so anything
|
||||||
|
// that is not plainly a hex colour has to be refused rather than passed on.
|
||||||
|
func TestRowColorsRefuseAnythingButHex(t *testing.T) {
|
||||||
|
in := RowColorSettings{Enabled: true, Rules: []RowColorRule{
|
||||||
|
{ID: "confirmed", Color: "#123abc", Enabled: true},
|
||||||
|
{ID: "sent", Color: "red; background:url(x)", Enabled: true},
|
||||||
|
{ID: "to_send", Color: "", Enabled: true},
|
||||||
|
}}
|
||||||
|
got := normRowColors(in)
|
||||||
|
|
||||||
|
byID := map[string]RowColorRule{}
|
||||||
|
for _, r := range got.Rules {
|
||||||
|
byID[r.ID] = r
|
||||||
|
}
|
||||||
|
if byID["confirmed"].Color != "#123abc" {
|
||||||
|
t.Errorf("a valid colour was rewritten: %q", byID["confirmed"].Color)
|
||||||
|
}
|
||||||
|
if byID["sent"].Color != rowColorDefaults["sent"] {
|
||||||
|
t.Errorf("an injection attempt survived: %q", byID["sent"].Color)
|
||||||
|
}
|
||||||
|
if byID["to_send"].Color != rowColorDefaults["to_send"] {
|
||||||
|
t.Errorf("an empty colour was kept: %q", byID["to_send"].Color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority lives in the data, not in a chain of ifs: a contact confirmed on
|
||||||
|
// LoTW AND by card is confirmed, not "sent, awaiting reply". The panel shows
|
||||||
|
// the rules in the order they apply, so that order must survive a round trip.
|
||||||
|
func TestRowColorsKeepPriorityOrder(t *testing.T) {
|
||||||
|
// Saved in a jumbled order, as a hand-edited settings row could be.
|
||||||
|
got := normRowColors(RowColorSettings{Rules: []RowColorRule{
|
||||||
|
{ID: "to_send", Color: "#111111"},
|
||||||
|
{ID: "confirmed", Color: "#222222"},
|
||||||
|
}})
|
||||||
|
if len(got.Rules) != len(rowColorOrder) {
|
||||||
|
t.Fatalf("got %d rules, want every one present", len(got.Rules))
|
||||||
|
}
|
||||||
|
for i, id := range rowColorOrder {
|
||||||
|
if got.Rules[i].ID != id {
|
||||||
|
t.Errorf("rule %d is %q, want %q", i, got.Rules[i].ID, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The saved colours survived the reordering: each stayed with its own rule.
|
||||||
|
byID := map[string]string{}
|
||||||
|
for _, r := range got.Rules {
|
||||||
|
byID[r.ID] = r.Color
|
||||||
|
}
|
||||||
|
if byID["to_send"] != "#111111" || byID["confirmed"] != "#222222" {
|
||||||
|
t.Errorf("colours moved between rules: %v", byID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style and strength are normalised, not rejected: they only shape a colour, and
|
||||||
|
// a value that got away is a slider, not a fault worth resetting the operator's
|
||||||
|
// whole choice for.
|
||||||
|
func TestRowColorsNormaliseStyleAndIntensity(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
inStyle string
|
||||||
|
inPct int
|
||||||
|
wantStyle string
|
||||||
|
wantPct int
|
||||||
|
}{
|
||||||
|
{"bar", 12, "bar", 12},
|
||||||
|
{"tint", 30, "tint", 30},
|
||||||
|
{"both", 45, "both", 45},
|
||||||
|
{"", 0, "bar", 12}, // never configured
|
||||||
|
{"wallpaper", 12, "bar", 12}, // not a style we draw
|
||||||
|
{"tint", 900, "tint", 45}, // clamped, not reset
|
||||||
|
{"tint", -5, "tint", 12},
|
||||||
|
} {
|
||||||
|
got := normRowColors(RowColorSettings{Style: tc.inStyle, Intensity: tc.inPct})
|
||||||
|
if got.Style != tc.wantStyle || got.Intensity != tc.wantPct {
|
||||||
|
t.Errorf("(%q,%d) -> (%q,%d), want (%q,%d)",
|
||||||
|
tc.inStyle, tc.inPct, got.Style, got.Intensity, tc.wantStyle, tc.wantPct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels are normalised to the canonical order, and anything unknown is
|
||||||
|
// dropped. Empty stays empty, because empty means "every channel" — a rule the
|
||||||
|
// operator has not narrowed must not silently become a rule about nothing.
|
||||||
|
func TestRowColorChannelsNormalise(t *testing.T) {
|
||||||
|
got := normRowColors(RowColorSettings{Rules: []RowColorRule{
|
||||||
|
{ID: "confirmed", Color: "#111111", Channels: []string{"LOTW", "pigeon", "qsl", "lotw"}},
|
||||||
|
{ID: "sent", Color: "#222222"},
|
||||||
|
}})
|
||||||
|
byID := map[string][]string{}
|
||||||
|
for _, r := range got.Rules {
|
||||||
|
byID[r.ID] = r.Channels
|
||||||
|
}
|
||||||
|
if len(byID["confirmed"]) != 2 || byID["confirmed"][0] != "qsl" || byID["confirmed"][1] != "lotw" {
|
||||||
|
t.Errorf("confirmed channels = %v, want [qsl lotw] — canonical order, deduped, unknown dropped", byID["confirmed"])
|
||||||
|
}
|
||||||
|
if len(byID["sent"]) != 0 {
|
||||||
|
t.Errorf("sent channels = %v, want empty (meaning every channel)", byID["sent"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The order IS the priority, and "to send" leads. A contact confirmed on LoTW
|
||||||
|
// that still owes a paper card must read as owing the card — put after
|
||||||
|
// "confirmed" it would go green and never be printed.
|
||||||
|
func TestToSendOutranksConfirmed(t *testing.T) {
|
||||||
|
got := normRowColors(RowColorSettings{})
|
||||||
|
if got.Rules[0].ID != "to_send" {
|
||||||
|
t.Errorf("first rule is %q, want to_send", got.Rules[0].ID)
|
||||||
|
}
|
||||||
|
if got.Rules[len(got.Rules)-1].ID != "worked" {
|
||||||
|
t.Errorf("last rule is %q, want worked as the catch-all", got.Rules[len(got.Rules)-1].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
+736
@@ -0,0 +1,736 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Auto-call — the wiring around internal/autocall.
|
||||||
|
//
|
||||||
|
// The DECISION is in that package, alone and tested. This file does the three
|
||||||
|
// things it cannot do for itself: cut the decode stream into periods, tell it
|
||||||
|
// what the log still needs from each station, and carry out what it decides.
|
||||||
|
//
|
||||||
|
// It lives in the backend rather than in the panel because it keys a
|
||||||
|
// transmitter: it must behave identically whether the FT decodes tab is open,
|
||||||
|
// behind another tab, or the window is minimised — and because every rule it
|
||||||
|
// applies is then a Go test rather than something only the air can check.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/autocall"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyAutoCallOn = "autocall.enabled"
|
||||||
|
keyAutoCallOnly = "autocall.only"
|
||||||
|
keyAutoCallAttempts = "autocall.attempts"
|
||||||
|
keyAutoCallWatched = "autocall.watched_attempts"
|
||||||
|
keyAutoCallMisses = "autocall.misses"
|
||||||
|
keyAutoCallRounds = "autocall.max_rounds"
|
||||||
|
// Periods, not minutes — see autocall.Settings.RestPeriods. A new key rather
|
||||||
|
// than a reinterpreted one: the old value was in minutes, and reading "2" as
|
||||||
|
// two periods or as two minutes are eight periods apart.
|
||||||
|
keyAutoCallRest = "autocall.rest_periods"
|
||||||
|
keyAutoCallOnScreen = "autocall.on_screen_only"
|
||||||
|
keyAutoCallTrace = "autocall.trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutoCallSettings is the panel's shape. Durations are in minutes because that
|
||||||
|
// is what the operator is asked for.
|
||||||
|
type AutoCallSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Only string `json:"only"`
|
||||||
|
// Attempts / WatchedAttempts: how many calls one station gets before it is
|
||||||
|
// released. The larger allowance is for a callsign on the watch list.
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
WatchedAttempts int `json:"watched_attempts"`
|
||||||
|
// Misses: periods in which the station itself transmits, with no decode of
|
||||||
|
// it, before it is given up on.
|
||||||
|
Misses int `json:"misses"`
|
||||||
|
// MaxRounds: how many series of calls one station gets in a session, and
|
||||||
|
// RestPeriods the pause between two of them, counted in the station's own
|
||||||
|
// transmit periods.
|
||||||
|
MaxRounds int `json:"max_rounds"`
|
||||||
|
RestPeriods int `json:"rest_periods"`
|
||||||
|
// OnScreenOnly: call only what the decodes panel is showing, so its filters
|
||||||
|
// steer the transmitter as well as the eye.
|
||||||
|
OnScreenOnly bool `json:"on_screen_only"`
|
||||||
|
// Trace writes one line per period to the log: what was on the air, why
|
||||||
|
// each station was refused, and what was decided. For diagnosing "it is not
|
||||||
|
// calling anything" — and it is a line every fifteen seconds, so it is off
|
||||||
|
// unless asked for.
|
||||||
|
Trace bool `json:"trace"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// intOr reads a stored integer, keeping a stored ZERO — which num() cannot,
|
||||||
|
// since it treats zero as "nothing set".
|
||||||
|
func intOr(v string, def int) int {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetAutoCallSettings() AutoCallSettings {
|
||||||
|
d := autocall.Defaults()
|
||||||
|
num := func(key string, def int) int {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(a.settingOr(key, "")))
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return AutoCallSettings{
|
||||||
|
// Never on from a stored value alone — see startAutoCall.
|
||||||
|
Enabled: a.settingOr(keyAutoCallOn, "0") == "1",
|
||||||
|
Only: strings.ToUpper(strings.TrimSpace(a.settingOr(keyAutoCallOnly, ""))),
|
||||||
|
Attempts: num(keyAutoCallAttempts, d.Attempts),
|
||||||
|
WatchedAttempts: num(keyAutoCallWatched, d.WatchedAttempts),
|
||||||
|
// On by default: the filters are in front of the operator, and a station
|
||||||
|
// they have hidden is one they have said they do not want.
|
||||||
|
OnScreenOnly: a.settingOr(keyAutoCallOnScreen, "1") == "1",
|
||||||
|
Trace: a.settingOr(keyAutoCallTrace, "0") == "1",
|
||||||
|
Misses: num(keyAutoCallMisses, d.Misses),
|
||||||
|
MaxRounds: num(keyAutoCallRounds, d.MaxRounds),
|
||||||
|
// Zero is meaningful (call it again next period), so it is read directly
|
||||||
|
// rather than through num(), which treats zero as "unset".
|
||||||
|
RestPeriods: intOr(a.settingOr(keyAutoCallRest, ""), d.RestPeriods),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveAutoCallSettings(s AutoCallSettings) error {
|
||||||
|
a.setSetting(keyAutoCallOn, map[bool]string{true: "1", false: "0"}[s.Enabled])
|
||||||
|
a.setSetting(keyAutoCallOnly, strings.ToUpper(strings.TrimSpace(s.Only)))
|
||||||
|
a.setSetting(keyAutoCallOnScreen, map[bool]string{true: "1", false: "0"}[s.OnScreenOnly])
|
||||||
|
a.setSetting(keyAutoCallTrace, map[bool]string{true: "1", false: "0"}[s.Trace])
|
||||||
|
for key, v := range map[string]int{
|
||||||
|
keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts,
|
||||||
|
keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds,
|
||||||
|
} {
|
||||||
|
if v > 0 {
|
||||||
|
a.setSetting(key, strconv.Itoa(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stored even at zero, unlike the counters above: no rest at all is a
|
||||||
|
// setting, not an empty field.
|
||||||
|
if s.RestPeriods >= 0 {
|
||||||
|
a.setSetting(keyAutoCallRest, strconv.Itoa(s.RestPeriods))
|
||||||
|
}
|
||||||
|
a.applyAutoCall()
|
||||||
|
applog.Printf("autocall: %v (only=%q, %d/%d calls, %d misses, %d rounds)",
|
||||||
|
s.Enabled, s.Only, s.Attempts, s.WatchedAttempts, s.Misses, s.MaxRounds)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAutoCallOnly is the chase-list field in the decodes toolbar.
|
||||||
|
//
|
||||||
|
// Its own binding rather than a settings round-trip: the toolbar knows one
|
||||||
|
// field, and handing back a whole struct it never read is how a Preferences
|
||||||
|
// window left open somewhere quietly reverts a limit that was just changed.
|
||||||
|
func (a *App) SetAutoCallOnly(list string) error {
|
||||||
|
s := a.GetAutoCallSettings()
|
||||||
|
s.Only = list
|
||||||
|
return a.SaveAutoCallSettings(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAutoCall is the toolbar switch above the decodes.
|
||||||
|
func (a *App) SetAutoCall(on bool) error {
|
||||||
|
s := a.GetAutoCallSettings()
|
||||||
|
s.Enabled = on
|
||||||
|
return a.SaveAutoCallSettings(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallEngine returns the engine, built on first use.
|
||||||
|
func (a *App) autoCallEngine() *autocall.Engine {
|
||||||
|
a.acMu.Lock()
|
||||||
|
defer a.acMu.Unlock()
|
||||||
|
if a.ac == nil {
|
||||||
|
a.ac = autocall.New(a.autoCallSettings())
|
||||||
|
}
|
||||||
|
return a.ac
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) autoCallSettings() autocall.Settings {
|
||||||
|
s := a.GetAutoCallSettings()
|
||||||
|
return autocall.Settings{
|
||||||
|
Enabled: s.Enabled, Only: s.Only, OnScreenOnly: s.OnScreenOnly,
|
||||||
|
Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts,
|
||||||
|
Misses: s.Misses, MaxRounds: s.MaxRounds,
|
||||||
|
RestPeriods: s.RestPeriods,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAutoCall pushes the settings into the engine, and clears its state when
|
||||||
|
// the feature is switched off — an operator turning it off is entitled to have
|
||||||
|
// it forget the station it was calling, not resume it half an hour later.
|
||||||
|
func (a *App) applyAutoCall() {
|
||||||
|
e := a.autoCallEngine()
|
||||||
|
s := a.autoCallSettings()
|
||||||
|
e.SetSettings(s)
|
||||||
|
if a.GetAutoCallSettings().Trace {
|
||||||
|
e.SetTrace(func(f string, args ...any) { applog.Printf("autocall: "+f, args...) })
|
||||||
|
} else {
|
||||||
|
e.SetTrace(nil)
|
||||||
|
}
|
||||||
|
if !s.Enabled {
|
||||||
|
e.Reset()
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acPeriod, a.acBuf = nil, nil
|
||||||
|
a.acJudged, a.acDirty = nil, nil
|
||||||
|
a.acMu.Unlock()
|
||||||
|
}
|
||||||
|
a.emitAutoCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetAutoCall is the operator's restart after the engine gave up on an
|
||||||
|
// explicit target: it clears every verdict, including the grey list.
|
||||||
|
func (a *App) ResetAutoCall() {
|
||||||
|
a.autoCallEngine().Reset()
|
||||||
|
a.emitAutoCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HaltAutoCall is the Halt button while a call is in progress.
|
||||||
|
//
|
||||||
|
// It does NOT clear the engine's state, which is what Halt used to do: that
|
||||||
|
// wiped the rests and the rounds along with everything else, so the station the
|
||||||
|
// operator had just stopped was eligible again in the same second and the next
|
||||||
|
// period called it straight back.
|
||||||
|
func (a *App) HaltAutoCall() {
|
||||||
|
call := a.autoCallEngine().Halt()
|
||||||
|
if call != "" {
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acReason = fmt.Sprintf("%s stopped by the operator — set aside until auto-call is switched off and on", call)
|
||||||
|
a.acMu.Unlock()
|
||||||
|
applog.Printf("autocall: %s", a.acReason)
|
||||||
|
}
|
||||||
|
a.emitAutoCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoCallStatus is what the toolbar shows.
|
||||||
|
type AutoCallStatus struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Only is the chase list, carried in the status so the field in the decodes
|
||||||
|
// toolbar and the one in Preferences are never two versions of the truth:
|
||||||
|
// whichever is typed into, both show it.
|
||||||
|
Only string `json:"only"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
// Waiting: what it would call if that station were not in a QSO.
|
||||||
|
Waiting string `json:"waiting"`
|
||||||
|
Calls int `json:"calls"`
|
||||||
|
Max int `json:"max"`
|
||||||
|
Misses int `json:"misses"`
|
||||||
|
MaxMiss int `json:"max_miss"`
|
||||||
|
Stopped bool `json:"stopped"`
|
||||||
|
// Greylisted counts the stations the operator has stopped this session, so
|
||||||
|
// the toolbar can say why a station on the air is never called.
|
||||||
|
Greylisted int `json:"greylisted"`
|
||||||
|
// Reason is the last decision in plain words. An auto-call that is doing
|
||||||
|
// nothing on purpose looks exactly like one that is broken.
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetAutoCallStatus() AutoCallStatus {
|
||||||
|
st := a.autoCallEngine().Status()
|
||||||
|
a.acMu.Lock()
|
||||||
|
reason := a.acReason
|
||||||
|
a.acMu.Unlock()
|
||||||
|
set := a.GetAutoCallSettings()
|
||||||
|
return AutoCallStatus{
|
||||||
|
Enabled: set.Enabled, Only: set.Only,
|
||||||
|
Target: st.Target, Waiting: st.Waiting, Calls: st.Attempts, Max: st.Max,
|
||||||
|
Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped,
|
||||||
|
Greylisted: a.autoCallEngine().Greylisted(),
|
||||||
|
Reason: reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) emitAutoCall() {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "autocall:status", a.GetAutoCallStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeAutoCallTarget adopts the station the operator has just clicked, so a
|
||||||
|
// manual pick gets the same watchdogs as an automatic one — the click is the
|
||||||
|
// choice of station, not a decision to call it for ever.
|
||||||
|
func (a *App) TakeAutoCallTarget(call, band, mode string) {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call = strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
if call == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.autoCallEngine().Take(autocall.Candidate{
|
||||||
|
Decode: autocall.Decode{Call: call, Band: band, Mode: mode, At: time.Now().UTC(), IsNew: true},
|
||||||
|
Need: a.autoCallNeed(call, band, mode),
|
||||||
|
Watched: a.autoCallWatched(call),
|
||||||
|
})
|
||||||
|
a.emitAutoCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The decode stream, cut into periods ───────────────────────────────────
|
||||||
|
|
||||||
|
// acDecode is one decode held until its period is complete.
|
||||||
|
type acDecode struct {
|
||||||
|
d autocall.Decode
|
||||||
|
tx bool // the decode is our own transmission echoed back
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallFeed takes one decode from the UDP loop.
|
||||||
|
//
|
||||||
|
// Decodes arrive one datagram at a time and a decision needs the whole period:
|
||||||
|
// the best station in it, and whether the target was there at all. They are
|
||||||
|
// therefore buffered under the period they belong to, and the period is judged
|
||||||
|
// when the next one starts — or, if the band goes quiet, by the sweeper below,
|
||||||
|
// which is what makes "not decoded for three of its periods" reachable when the
|
||||||
|
// answer is that nothing is being decoded at all.
|
||||||
|
func (a *App) autoCallFeed(d autocall.Decode) {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inst := d.Instance
|
||||||
|
key := acPeriodKey(d.At, d.TRPeriod)
|
||||||
|
a.acMu.Lock()
|
||||||
|
if a.acPeriod == nil {
|
||||||
|
a.acPeriod, a.acAt, a.acTR, a.acBuf = map[string]string{}, map[string]time.Time{}, map[string]int{}, map[string][]acDecode{}
|
||||||
|
a.acFed = map[string]time.Time{}
|
||||||
|
a.acJudged, a.acDirty = map[string]string{}, map[string]bool{}
|
||||||
|
}
|
||||||
|
if prev := a.acPeriod[inst]; prev != "" && prev != key {
|
||||||
|
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
|
||||||
|
// Only if something in it has NOT been judged. The buffer now outlives
|
||||||
|
// the judgement (see below), so without this the arrival of the next
|
||||||
|
// period would judge the previous one a second time on the very same
|
||||||
|
// decodes — and act on them, a slot late.
|
||||||
|
pending := a.acDirty[inst] || a.acJudged[inst] != prev
|
||||||
|
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
||||||
|
a.acBuf[inst] = []acDecode{{d: d}}
|
||||||
|
a.acFed[inst], a.acDirty[inst] = time.Now(), true
|
||||||
|
a.acMu.Unlock()
|
||||||
|
// The previous period ends here whatever the sweeper was going to do: a
|
||||||
|
// decode stamped with the next slot is proof the old one is over.
|
||||||
|
if pending {
|
||||||
|
a.autoCallJudge(inst, prev, prevAt, prevTR, buf)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// APPENDED, never restarted. The buffer survives the period being judged,
|
||||||
|
// so a decode that arrives after the others belongs to the same period and
|
||||||
|
// is weighed against all of them.
|
||||||
|
//
|
||||||
|
// It used to be dropped and then judged on its own: the sweeper cleared the
|
||||||
|
// buffer, a straggler opened a "new" one under the same key, and the ladder
|
||||||
|
// was applied to whatever handful had come late — with the other thirty
|
||||||
|
// stations of that period nowhere in sight. A deep decode arriving a second
|
||||||
|
// after the burst is exactly the station worth calling.
|
||||||
|
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
||||||
|
a.acFed[inst], a.acDirty[inst] = time.Now(), true
|
||||||
|
a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d})
|
||||||
|
a.acMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// acQuiet is how long a period is left open after its LAST decode arrives.
|
||||||
|
//
|
||||||
|
// This is the whole timing budget of the feature. A decoder finishes a period
|
||||||
|
// and sends its decodes about a second before the next slot opens, so the
|
||||||
|
// answer has to be back before that boundary — a reply that arrives after it
|
||||||
|
// makes the decoder start its call several seconds into the slot, which is what
|
||||||
|
// an operator sees as "it calls late" and what a station on the other end sees
|
||||||
|
// as a message it cannot decode.
|
||||||
|
//
|
||||||
|
// It was a whole slot plus four seconds, measured from the DECODE'S OWN
|
||||||
|
// TIMESTAMP — the start of the period, not the moment it arrived — so the
|
||||||
|
// answer left about four seconds INTO the next slot, every time.
|
||||||
|
//
|
||||||
|
// 800 ms: long enough for a busy period's decodes to arrive together (measured
|
||||||
|
// in bursts of a few hundred milliseconds), short enough to answer inside the
|
||||||
|
// same second they landed.
|
||||||
|
const acQuiet = 800 * time.Millisecond
|
||||||
|
|
||||||
|
// autoCallSweep closes the periods nothing has closed for us. Called on a timer.
|
||||||
|
//
|
||||||
|
// Per receiver, because with two decoders one may fall silent while the other
|
||||||
|
// is busy — and it is the silent one's period that has to close for a missed
|
||||||
|
// period to be counted at all.
|
||||||
|
func (a *App) autoCallSweep() {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type due struct {
|
||||||
|
inst, key string
|
||||||
|
at time.Time
|
||||||
|
tr int
|
||||||
|
buf []acDecode
|
||||||
|
}
|
||||||
|
var ready []due
|
||||||
|
a.acMu.Lock()
|
||||||
|
for inst, key := range a.acPeriod {
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tr := a.acTR[inst]
|
||||||
|
if tr <= 0 {
|
||||||
|
tr = 15
|
||||||
|
}
|
||||||
|
// Measured from when the last decode ARRIVED, not from the period it
|
||||||
|
// belongs to: a decode is stamped with the start of its own slot, so
|
||||||
|
// waiting "a slot plus four seconds" from that stamp is waiting until
|
||||||
|
// the middle of the NEXT slot. See acQuiet.
|
||||||
|
if time.Since(a.acFed[inst]) < acQuiet {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Judged once per period, and again only when something new has come in
|
||||||
|
// for it. The period itself is kept open until a decode from the NEXT one
|
||||||
|
// arrives, so a straggler is judged with the whole period behind it.
|
||||||
|
if a.acJudged[inst] == key && !a.acDirty[inst] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
|
||||||
|
a.acJudged[inst], a.acDirty[inst] = key, false
|
||||||
|
}
|
||||||
|
a.acMu.Unlock()
|
||||||
|
for _, d := range ready {
|
||||||
|
a.autoCallJudge(d.inst, d.key, d.at, d.tr, d.buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallSilence is the empty period. With the band dead, no decode ever
|
||||||
|
// arrives to close the next one, and the target's absence would never be
|
||||||
|
// counted — so a period with nothing in it is still a period.
|
||||||
|
//
|
||||||
|
// Only for the receiver the target is being called on: an idle second decoder
|
||||||
|
// has no periods to miss.
|
||||||
|
func (a *App) autoCallSilence() {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inst, target := a.autoCallEngine().TargetInstance()
|
||||||
|
if target == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.acMu.Lock()
|
||||||
|
// Nothing pending for this receiver, and nothing judged for a while: the
|
||||||
|
// band has gone quiet under it.
|
||||||
|
quiet := !a.acDirty[inst] && time.Since(a.acLastJudge) > 20*time.Second
|
||||||
|
tr := a.acTR[inst]
|
||||||
|
a.acMu.Unlock()
|
||||||
|
if !quiet {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
a.autoCallJudge(inst, acPeriodKey(now, tr), now, tr, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func acPeriodKey(at time.Time, trSec int) string {
|
||||||
|
if trSec <= 0 {
|
||||||
|
trSec = 15
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", at.UTC().Unix()/int64(trSec))
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallJudge resolves what the log needs from each station in the period,
|
||||||
|
// runs the decision, and carries it out.
|
||||||
|
func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDecode) {
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acLastJudge = time.Now()
|
||||||
|
a.acMu.Unlock()
|
||||||
|
|
||||||
|
// One status call for the whole period. It reads a cached worked-index, but
|
||||||
|
// it is still per-callsign work and a busy period is thirty of them.
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var q []SpotQuery
|
||||||
|
var uniq []acDecode
|
||||||
|
for _, dd := range buf {
|
||||||
|
k := dd.d.Call + "|" + dd.d.Band + "|" + dd.d.Mode
|
||||||
|
if seen[k] {
|
||||||
|
// The same station twice in one period is one candidate, judged on
|
||||||
|
// its most callable line — the engine's bestOf does that, so both
|
||||||
|
// decodes are kept; only the status lookup is deduplicated.
|
||||||
|
uniq = append(uniq, dd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[k] = true
|
||||||
|
uniq = append(uniq, dd)
|
||||||
|
q = append(q, SpotQuery{Call: dd.d.Call, Band: dd.d.Band, Mode: dd.d.Mode})
|
||||||
|
}
|
||||||
|
status := map[string]SpotStatus{}
|
||||||
|
for _, st := range a.ClusterSpotStatuses(q) {
|
||||||
|
status[st.Call+"|"+st.Band+"|"+st.Mode] = st
|
||||||
|
}
|
||||||
|
|
||||||
|
chase := a.autoCallChase()
|
||||||
|
cands := make([]autocall.Candidate, 0, len(uniq))
|
||||||
|
for _, dd := range uniq {
|
||||||
|
st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode]
|
||||||
|
c := candidateOf(dd.d, st, chase)
|
||||||
|
c.Watched = a.autoCallWatched(dd.d.Call)
|
||||||
|
c.Hidden = a.autoCallHidden(dd.d.Call)
|
||||||
|
cands = append(cands, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := a.autoCallTX()
|
||||||
|
|
||||||
|
act := a.autoCallEngine().OnPeriod(autocall.Period{
|
||||||
|
Instance: inst, Key: key, At: at, TRPeriod: tr, Decodes: cands, TX: tx,
|
||||||
|
MyCall: a.opCall,
|
||||||
|
})
|
||||||
|
a.autoCallDo(act)
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidateOf turns one decode and the log's verdict on it into a candidate.
|
||||||
|
//
|
||||||
|
// Split out and kept pure because ONE line of it was wrong for weeks and
|
||||||
|
// nothing could catch it: the entity's verdict was read as the station's.
|
||||||
|
// chaseExtras is what the operator hunts BESIDES entities — the cluster's
|
||||||
|
// orthogonal markers, from the same switches that decide whether the badges are
|
||||||
|
// shown at all (Settings → DX Cluster). One answer for the eye and the
|
||||||
|
// transmitter: a marker withdrawn from the screen is not one to call for.
|
||||||
|
type chaseExtras struct{ pota, grid, pfx, county, state bool }
|
||||||
|
|
||||||
|
func candidateOf(d autocall.Decode, st SpotStatus, ch chaseExtras) autocall.Candidate {
|
||||||
|
c := autocall.Candidate{
|
||||||
|
Decode: d,
|
||||||
|
// The ENTITY's verdict decides what is still needed…
|
||||||
|
Need: autoCallNeedOf(st.Status),
|
||||||
|
// …and THIS CALLSIGN on this band and mode decides whether calling it
|
||||||
|
// would be a duplicate.
|
||||||
|
//
|
||||||
|
// Status was used for both. "worked" there means the COUNTRY is in the
|
||||||
|
// log on this band and mode, so on a band where the operator has most of
|
||||||
|
// them, nearly every station on the air was refused as already worked —
|
||||||
|
// a trace of one evening shows twenty decodes out of twenty-one turned
|
||||||
|
// away that way, the watched DXpedition among them.
|
||||||
|
Worked: st.WorkedSlot,
|
||||||
|
// The need exists only because a QSL never came: worth chasing, and worth
|
||||||
|
// less than the same need never worked at all.
|
||||||
|
Unconfirmed: st.UnconfStatus,
|
||||||
|
}
|
||||||
|
// NOTHING LEFT ON THE ENTITY, AND STILL WORTH A CALL.
|
||||||
|
//
|
||||||
|
// A prefix, a square, a county, a state, a park: never worked, orthogonal to
|
||||||
|
// the entity's verdict, and exactly what the operator ticked in the chase
|
||||||
|
// settings. They ranked at nothing-needed, so auto-call sat through a
|
||||||
|
// never-worked WPX prefix calling CQ and did not answer it.
|
||||||
|
//
|
||||||
|
// Only when the entity has nothing to add — a new band that is ALSO a new
|
||||||
|
// prefix is a new band, and says so.
|
||||||
|
if c.Need == autocall.NeedNone {
|
||||||
|
switch {
|
||||||
|
case ch.pfx && st.NewPfx:
|
||||||
|
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "prefix", st.UnconfPfx
|
||||||
|
case ch.county && st.NewCounty:
|
||||||
|
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "county", st.UnconfCty
|
||||||
|
case ch.state && st.NewState:
|
||||||
|
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "state", st.UnconfState
|
||||||
|
case ch.grid && st.NewGrid:
|
||||||
|
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "square", st.GridState == "unconf"
|
||||||
|
case ch.pota && st.NewPOTA:
|
||||||
|
c.Need, c.Extra = autocall.NeedExtra, "park"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallChase reads the chase switches the cluster and the decode list use.
|
||||||
|
//
|
||||||
|
// Read once per period rather than per decode: they are settings-store reads,
|
||||||
|
// and the period loop runs over every station on the band.
|
||||||
|
func (a *App) autoCallChase() chaseExtras {
|
||||||
|
on := func(key string) bool {
|
||||||
|
if a.settings == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
v, _ := a.settings.Get(a.ctx, "ui.opslog."+key)
|
||||||
|
return v != "0" // unset means on, as it does on the screen
|
||||||
|
}
|
||||||
|
return chaseExtras{
|
||||||
|
pota: on("chasePota"),
|
||||||
|
grid: on("chaseGrids"),
|
||||||
|
pfx: on("chasePfx"),
|
||||||
|
county: on("chaseCounty"),
|
||||||
|
state: on("chaseState"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallDo carries out a decision and records it.
|
||||||
|
func (a *App) autoCallDo(act autocall.Action) {
|
||||||
|
if act.Reason != "" {
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acReason = act.Reason
|
||||||
|
a.acMu.Unlock()
|
||||||
|
applog.Printf("autocall: %s", act.Reason)
|
||||||
|
}
|
||||||
|
switch act.Kind {
|
||||||
|
case autocall.DoReply:
|
||||||
|
d := act.Decode
|
||||||
|
if err := a.AnswerDecode(d.Instance, d.Ms, d.SNR, d.DT, d.AudioHz, d.ModeRaw, d.MsgRaw, d.LowConf); err != nil {
|
||||||
|
applog.Printf("autocall: the call to %s could not be sent: %v", d.Call, err)
|
||||||
|
}
|
||||||
|
case autocall.DoHalt:
|
||||||
|
// Soft: let the over finish, then stop transmitting. Hard: stop now.
|
||||||
|
// The engine decides — see Action.Soft.
|
||||||
|
if err := a.HaltDecodeTx("", act.Soft); err != nil {
|
||||||
|
applog.Printf("autocall: halt failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if act.Kind != autocall.DoNothing || act.Reason != "" {
|
||||||
|
a.emitAutoCall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallNoteTX is the attempt counter, fed from the decoder's own status.
|
||||||
|
//
|
||||||
|
// ONCE PER TRANSMIT PERIOD. Status arrives every second and says "transmitting"
|
||||||
|
// throughout the over, so counting each one would spend the whole allowance of
|
||||||
|
// seven calls inside a single fifteen-second slot — the counter has to measure
|
||||||
|
// transmissions, not seconds of carrier.
|
||||||
|
func (a *App) autoCallNoteTX(tx autocall.TXState) {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.acMu.Lock()
|
||||||
|
// Per receiver as well as per period: in a split view both decoders report
|
||||||
|
// their own transmissions, and one key for both would let the second one's
|
||||||
|
// carrier swallow the first one's count.
|
||||||
|
key := tx.Instance + "|" + acPeriodKey(time.Now().UTC(), a.acTR[tx.Instance])
|
||||||
|
if a.acTXPeriod == key {
|
||||||
|
a.acMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.acTXPeriod = key
|
||||||
|
a.acMu.Unlock()
|
||||||
|
a.autoCallDo(a.autoCallEngine().NoteTX(tx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallTX is the last transmit state reported, as the engine wants it.
|
||||||
|
func (a *App) autoCallTX() autocall.TXState {
|
||||||
|
a.acMu.Lock()
|
||||||
|
defer a.acMu.Unlock()
|
||||||
|
return a.acTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) autoCallSetTX(tx autocall.TXState) {
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acTX = tx
|
||||||
|
a.acMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAutoCallVisible is the decodes panel saying what it is SHOWING.
|
||||||
|
//
|
||||||
|
// The panel owns the filters and therefore owns the answer: reimplementing them
|
||||||
|
// here would give the screen and the transmitter two definitions of the same
|
||||||
|
// word, which is how they end up disagreeing. It sends the callsigns that
|
||||||
|
// survive its filters, and the engine calls nothing else.
|
||||||
|
//
|
||||||
|
// An empty list with active=false means "no filtering in force" — the panel was
|
||||||
|
// closed, or has never been opened this session — and the ladder decides alone.
|
||||||
|
func (a *App) SetAutoCallVisible(calls []string, active bool) {
|
||||||
|
set := make(map[string]bool, len(calls))
|
||||||
|
for _, c := range calls {
|
||||||
|
if c = strings.ToUpper(strings.TrimSpace(c)); c != "" {
|
||||||
|
set[c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acVisible, a.acVisibleOn = set, active
|
||||||
|
a.acMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallHidden reports whether the panel's filters are keeping a station off
|
||||||
|
// the screen. Unknown when nothing is being published: not hidden.
|
||||||
|
func (a *App) autoCallHidden(call string) bool {
|
||||||
|
a.acMu.Lock()
|
||||||
|
defer a.acMu.Unlock()
|
||||||
|
if !a.acVisibleOn {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !a.acVisible[strings.ToUpper(strings.TrimSpace(call))]
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallNeedOf maps the cluster's own status vocabulary onto the ladder. One
|
||||||
|
// vocabulary for both, so a station that reads NEW BAND in the decodes list is
|
||||||
|
// the same NEW BAND the auto-call ranks — two answers to one question is how
|
||||||
|
// the panel and the caller quietly start disagreeing.
|
||||||
|
func autoCallNeedOf(status string) autocall.Need {
|
||||||
|
switch status {
|
||||||
|
case "new":
|
||||||
|
return autocall.NeedDXCC
|
||||||
|
case "new-band-mode", "new-band":
|
||||||
|
// New on both counts is at least a new band, and it is the better catch
|
||||||
|
// of the two — it must not fall below a plain new band.
|
||||||
|
return autocall.NeedBand
|
||||||
|
case "new-mode":
|
||||||
|
return autocall.NeedMode
|
||||||
|
case "new-slot":
|
||||||
|
return autocall.NeedSlot
|
||||||
|
}
|
||||||
|
return autocall.NeedNone
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) autoCallNeed(call, band, mode string) autocall.Need {
|
||||||
|
st := a.ClusterSpotStatuses([]SpotQuery{{Call: call, Band: band, Mode: mode}})
|
||||||
|
if len(st) == 0 {
|
||||||
|
return autocall.NeedNone
|
||||||
|
}
|
||||||
|
return autoCallNeedOf(st[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallWatched asks the watch list, which is the same list the spot alerts
|
||||||
|
// and the cluster colouring use.
|
||||||
|
func (a *App) autoCallWatched(call string) bool {
|
||||||
|
if a.watchlist == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := a.watchlist.Match(strings.ToUpper(strings.TrimSpace(call)))
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// startAutoCall starts the engine's loop, with auto-call OFF.
|
||||||
|
//
|
||||||
|
// It is never on from a stored value. This is the one setting in the program
|
||||||
|
// that puts the station on the air by itself, and the operator who left it on
|
||||||
|
// last night is not necessarily the one at the desk now — nor necessarily at
|
||||||
|
// the desk at all: OpsLog starts with Windows, and a rig that powers up with it
|
||||||
|
// would begin calling into an empty shack, on whatever band the radio happens
|
||||||
|
// to be on, hours after anybody decided that was a good idea.
|
||||||
|
//
|
||||||
|
// Arming it is one click, and it is a click somebody has to make.
|
||||||
|
func (a *App) startAutoCall() {
|
||||||
|
a.disarmAutoCall("launch")
|
||||||
|
go a.autoCallLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// disarmAutoCall switches auto-call off and writes that down.
|
||||||
|
//
|
||||||
|
// The setting is what the toolbar and the settings panel both read, so turning
|
||||||
|
// the engine off without storing it would show a lit switch over a silent
|
||||||
|
// transmitter — and the operator's next click, meaning "on", would send "off".
|
||||||
|
func (a *App) disarmAutoCall(why string) {
|
||||||
|
if a.settingOr(keyAutoCallOn, "0") == "1" {
|
||||||
|
applog.Printf("autocall: off at %s — it is never armed from a stored setting", why)
|
||||||
|
}
|
||||||
|
a.setSetting(keyAutoCallOn, "0")
|
||||||
|
a.applyAutoCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) autoCallLoop() {
|
||||||
|
// A quarter of a second. The sweeper is what closes a period, so its tick is
|
||||||
|
// part of the same budget as acQuiet: a two-second tick added up to two
|
||||||
|
// seconds of its own to every answer, which is most of the margin there is.
|
||||||
|
// The work per tick is a map read.
|
||||||
|
t := time.NewTicker(250 * time.Millisecond)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.autoCallSweep()
|
||||||
|
a.autoCallSilence()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/autocall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The entity's verdict is not the station's.
|
||||||
|
//
|
||||||
|
// From the air: on 10 m, where most countries are already in the log, the
|
||||||
|
// engine refused twenty decodes out of twenty-one as "worked" — a watched
|
||||||
|
// DXpedition calling CQ among them — because the ENTITY's status was read as
|
||||||
|
// the station's.
|
||||||
|
// allChased is the default: every orthogonal marker ticked.
|
||||||
|
var allChased = chaseExtras{pota: true, grid: true, pfx: true, county: true, state: true}
|
||||||
|
|
||||||
|
func TestCandidateWorkedIsTheCallsignNotTheEntity(t *testing.T) {
|
||||||
|
d := autocall.Decode{Call: "J38DX", Band: "10m", Mode: "FT8", IsNew: true}
|
||||||
|
|
||||||
|
// Grenada worked on 10 m FT8, this callsign never worked: nothing is needed
|
||||||
|
// from it, and calling it is NOT a duplicate.
|
||||||
|
c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: false}, allChased)
|
||||||
|
if c.Worked {
|
||||||
|
t.Error("a station never worked was refused because its entity was")
|
||||||
|
}
|
||||||
|
if c.Need != autocall.NeedNone {
|
||||||
|
t.Errorf("need = %v on a worked entity, want none", c.Need)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same callsign already in the log on this band and mode IS a duplicate.
|
||||||
|
if c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: true}, allChased); !c.Worked {
|
||||||
|
t.Error("a callsign already worked on this band and mode was not flagged")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a real need still carries through, with the unconfirmed distinction.
|
||||||
|
c = candidateOf(d, SpotStatus{Status: "new-band", UnconfStatus: true}, allChased)
|
||||||
|
if c.Need != autocall.NeedBand || !c.Unconfirmed {
|
||||||
|
t.Errorf("new-band unconfirmed came through as %v (unconf=%v)", c.Need, c.Unconfirmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A station whose ENTITY has nothing left to give can still be the reason the
|
||||||
|
// operator is on the band: a WPX prefix, a county, a square, a park that has
|
||||||
|
// never been worked. Reported from the air — auto-call sat through a
|
||||||
|
// never-worked prefix calling CQ and did nothing.
|
||||||
|
func TestOrthogonalMarkersAreWorthACall(t *testing.T) {
|
||||||
|
d := autocall.Decode{Call: "BH2SWB", Band: "10m", Mode: "FT8", IsNew: true}
|
||||||
|
worked := SpotStatus{Status: "worked"}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
what string
|
||||||
|
st SpotStatus
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"prefix", SpotStatus{Status: "worked", NewPfx: true}, "prefix"},
|
||||||
|
{"county", SpotStatus{Status: "worked", NewCounty: true}, "county"},
|
||||||
|
{"state", SpotStatus{Status: "worked", NewState: true}, "state"},
|
||||||
|
{"square", SpotStatus{Status: "worked", NewGrid: true}, "square"},
|
||||||
|
{"park", SpotStatus{Status: "worked", NewPOTA: true}, "park"},
|
||||||
|
} {
|
||||||
|
c := candidateOf(d, tc.st, allChased)
|
||||||
|
if c.Need != autocall.NeedExtra || c.Extra != tc.want {
|
||||||
|
t.Errorf("%s: need=%v extra=%q, want extra %q", tc.what, c.Need, c.Extra, tc.want)
|
||||||
|
}
|
||||||
|
// And not when the operator does not chase that kind of thing: the
|
||||||
|
// switch that withdraws the badge withdraws the call with it.
|
||||||
|
if c := candidateOf(d, tc.st, chaseExtras{}); c.Need != autocall.NeedNone {
|
||||||
|
t.Errorf("%s: called for a marker the operator does not chase", tc.what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// It is the LOWEST rung: a real need on the entity still says what it is.
|
||||||
|
if c := candidateOf(d, SpotStatus{Status: "new-band", NewPfx: true}, allChased); c.Need != autocall.NeedBand {
|
||||||
|
t.Errorf("need = %v — a new band that is also a new prefix is a new band", c.Need)
|
||||||
|
}
|
||||||
|
// Nothing anywhere is still nothing.
|
||||||
|
if c := candidateOf(d, worked, allChased); c.Need != autocall.NeedNone {
|
||||||
|
t.Errorf("need = %v on a station with nothing to gain", c.Need)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
@@ -26,6 +28,12 @@ type AutostartProgram struct {
|
|||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Args string `json:"args"`
|
Args string `json:"args"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
// CloseOnExit asks for this program to be closed when OpsLog closes.
|
||||||
|
//
|
||||||
|
// Per program and not one switch for the list, because the answer differs
|
||||||
|
// inside one station: an operator wants WSJT-X gone with the logger and the
|
||||||
|
// rotator controller left running.
|
||||||
|
CloseOnExit bool `json:"close_on_exit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutostartLaunchResult reports what happened for one program when launching.
|
// AutostartLaunchResult reports what happened for one program when launching.
|
||||||
@@ -106,6 +114,52 @@ func (a *App) LaunchAutostartProgram(id string) (AutostartLaunchResult, error) {
|
|||||||
return AutostartLaunchResult{}, fmt.Errorf("program %q not found", id)
|
return AutostartLaunchResult{}, fmt.Errorf("program %q not found", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// launched remembers the process id of every program OPSLOG started, keyed by
|
||||||
|
// program id.
|
||||||
|
//
|
||||||
|
// Only what we started is ever closed. A copy of WSJT-X the operator opened
|
||||||
|
// themselves — before OpsLog, for something else entirely — is theirs, and
|
||||||
|
// closing it because a logger happened to quit would be taking a decision that
|
||||||
|
// was never asked for. That is also why "already running" stores nothing.
|
||||||
|
var launched = struct {
|
||||||
|
sync.Mutex
|
||||||
|
pid map[string]int
|
||||||
|
}{pid: map[string]int{}}
|
||||||
|
|
||||||
|
// CloseAutostartPrograms closes the programs marked "close with OpsLog" — the
|
||||||
|
// ones OpsLog itself launched this session.
|
||||||
|
//
|
||||||
|
// A polite close, never a kill: taskkill without /F posts WM_CLOSE, so WSJT-X
|
||||||
|
// writes its settings and its log the way it would if the operator had clicked
|
||||||
|
// the cross. Forcing it would lose exactly the state an operator cares about,
|
||||||
|
// and a program that ignores a close request is entitled to.
|
||||||
|
func (a *App) CloseAutostartPrograms() {
|
||||||
|
progs, _ := a.GetAutostartPrograms()
|
||||||
|
for _, p := range progs {
|
||||||
|
if !p.CloseOnExit {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
launched.Lock()
|
||||||
|
pid, ok := launched.pid[p.ID]
|
||||||
|
delete(launched.pid, p.ID)
|
||||||
|
launched.Unlock()
|
||||||
|
if !ok || pid <= 0 {
|
||||||
|
continue // not started by us this session — not ours to close
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(p.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = filepath.Base(p.Path)
|
||||||
|
}
|
||||||
|
cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid))
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
applog.Printf("autostart: could not close %s (pid %d): %v — %s", name, pid, err, strings.TrimSpace(string(out)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applog.Printf("autostart: asked %s (pid %d) to close", name, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// launchProgram starts one program unless its executable is already running.
|
// launchProgram starts one program unless its executable is already running.
|
||||||
func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchResult {
|
func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchResult {
|
||||||
res := AutostartLaunchResult{ID: p.ID, Name: p.Name}
|
res := AutostartLaunchResult{ID: p.ID, Name: p.Name}
|
||||||
@@ -131,6 +185,13 @@ func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchR
|
|||||||
res.Status, res.Message = "error", err.Error()
|
res.Status, res.Message = "error", err.Error()
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
// Remembered so it can be closed again on exit, if asked. Recorded BEFORE
|
||||||
|
// the wait goroutine, which releases the handle.
|
||||||
|
if cmd.Process != nil {
|
||||||
|
launched.Lock()
|
||||||
|
launched.pid[p.ID] = cmd.Process.Pid
|
||||||
|
launched.Unlock()
|
||||||
|
}
|
||||||
// Don't wait on the child — it runs independently of OpsLog. Release the
|
// Don't wait on the child — it runs independently of OpsLog. Release the
|
||||||
// handle so we don't accumulate zombies.
|
// handle so we don't accumulate zombies.
|
||||||
go func() { _ = cmd.Wait() }()
|
go func() { _ = cmd.Wait() }()
|
||||||
|
|||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// "Have I worked this reference before?" — the NEW badge on a QSO's award
|
||||||
|
// references.
|
||||||
|
//
|
||||||
|
// # Where the answer comes from, and where it must NOT come from
|
||||||
|
//
|
||||||
|
// The obvious source is the materialised award_refs column: it is already on
|
||||||
|
// every row and one query reads it. It is also the wrong answer twice over.
|
||||||
|
// That column holds DISPLAY LABELS, not references — depending on the award's
|
||||||
|
// RefDisplay it can read "Krasnogvardeysky" or "ST-25 — Krasnogvardeysky", and
|
||||||
|
// DXCC stores a country name — and it is a derived cache that lags: a QSO whose
|
||||||
|
// references were back-filled (see BackfillRDA) counts on the Awards grid long
|
||||||
|
// before anything recomputes its column. The first build of this badge used it
|
||||||
|
// and marked a district worked on five bands as NEW.
|
||||||
|
//
|
||||||
|
// So the answer comes from the same place the Awards grid's answer comes from:
|
||||||
|
// award.MatchQSO over the logbook. What is engineered here is the COST of that,
|
||||||
|
// because the question is asked while the operator is logging:
|
||||||
|
//
|
||||||
|
// - the set is per award CODE, and built only for codes actually asked about;
|
||||||
|
// - it reuses the award snapshot when one is already in memory, which it
|
||||||
|
// usually is (the Awards tab, the cluster status index);
|
||||||
|
// - otherwise it STREAMS the logbook rather than materialising a second copy
|
||||||
|
// of it — the references of one award are a few thousand short strings;
|
||||||
|
// - and it is built in the BACKGROUND. A caller that asks before the set is
|
||||||
|
// ready is told so and gets no badge, never a wait and never a wrong badge.
|
||||||
|
//
|
||||||
|
// A freshly logged QSO's references are folded into the sets, so a normal
|
||||||
|
// evening of logging never rebuilds anything.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/award"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// workedRefIndex is the per-code set of references already in the log, plus the
|
||||||
|
// logbook revision it describes.
|
||||||
|
type workedRefIndex struct {
|
||||||
|
rev string
|
||||||
|
byCode map[string]map[string]struct{}
|
||||||
|
building map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// AwardRefNewResult answers one badge request.
|
||||||
|
type AwardRefNewResult struct {
|
||||||
|
// New maps "CODE@REF" to "never worked". An entry whose award set is still
|
||||||
|
// building is ABSENT rather than false: "not worked" and "not known yet"
|
||||||
|
// must not look the same to the badge.
|
||||||
|
New map[string]bool `json:"new"`
|
||||||
|
// Pending is true when at least one award set is still being built, and the
|
||||||
|
// caller should ask again shortly.
|
||||||
|
Pending bool `json:"pending"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AwardRefsNew reports which of the given "CODE@REF" entries have never been
|
||||||
|
// worked. Returns immediately, always.
|
||||||
|
func (a *App) AwardRefsNew(entries []string) AwardRefNewResult {
|
||||||
|
res := AwardRefNewResult{New: map[string]bool{}}
|
||||||
|
if a.qso == nil || len(entries) == 0 {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
rev, err := a.qso.Revision(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return res // logbook unreachable: no badge is the honest answer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group the request by award code, so one code is looked up once however
|
||||||
|
// many of its references are on the QSO.
|
||||||
|
want := map[string][]string{}
|
||||||
|
for _, e := range entries {
|
||||||
|
code, _, ok := splitRefEntry(e)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
want[code] = append(want[code], e)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.workedRefMu.Lock()
|
||||||
|
if a.workedRef.rev != rev || a.workedRef.byCode == nil {
|
||||||
|
// The logbook moved on: every set describes a log that no longer exists.
|
||||||
|
a.workedRef = workedRefIndex{rev: rev, byCode: map[string]map[string]struct{}{}, building: map[string]bool{}}
|
||||||
|
}
|
||||||
|
var build []string
|
||||||
|
for code := range want {
|
||||||
|
set, ready := a.workedRef.byCode[code]
|
||||||
|
if !ready {
|
||||||
|
if !a.workedRef.building[code] {
|
||||||
|
a.workedRef.building[code] = true
|
||||||
|
build = append(build, code)
|
||||||
|
}
|
||||||
|
res.Pending = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range want[code] {
|
||||||
|
_, ref, _ := splitRefEntry(e)
|
||||||
|
_, worked := set[ref]
|
||||||
|
res.New[e] = !worked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.workedRefMu.Unlock()
|
||||||
|
|
||||||
|
for _, code := range build {
|
||||||
|
go a.buildWorkedRefs(code, rev)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitRefEntry splits "RDA@ST-25" into its uppercased code and reference.
|
||||||
|
func splitRefEntry(e string) (code, ref string, ok bool) {
|
||||||
|
at := strings.Index(e, "@")
|
||||||
|
if at <= 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(e[:at]))
|
||||||
|
ref = strings.ToUpper(strings.TrimSpace(e[at+1:]))
|
||||||
|
return code, ref, code != "" && ref != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildWorkedRefs collects every reference of one award already in the log.
|
||||||
|
func (a *App) buildWorkedRefs(code string, rev string) {
|
||||||
|
set := map[string]struct{}{}
|
||||||
|
defer func() {
|
||||||
|
a.workedRefMu.Lock()
|
||||||
|
// Publish only against the revision we were asked for: a QSO logged while
|
||||||
|
// this ran has already reset the index, and a set built from the older log
|
||||||
|
// would claim to describe the newer one.
|
||||||
|
if a.workedRef.rev == rev && a.workedRef.byCode != nil {
|
||||||
|
a.workedRef.byCode[code] = set
|
||||||
|
}
|
||||||
|
delete(a.workedRef.building, code)
|
||||||
|
a.workedRefMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var def *award.Def
|
||||||
|
for _, d := range a.awardDefs() {
|
||||||
|
if strings.EqualFold(d.Code, code) {
|
||||||
|
dd := d
|
||||||
|
def = &dd
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def == nil {
|
||||||
|
return // unknown award: an empty set, so nothing is ever badged NEW
|
||||||
|
}
|
||||||
|
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
|
||||||
|
collect := func(q *qso.QSO) {
|
||||||
|
for _, r := range award.MatchQSO(*def, metas, q) {
|
||||||
|
set[strings.ToUpper(strings.TrimSpace(r))] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The snapshot is already enriched and already in memory whenever the Awards
|
||||||
|
// tab has been open — by far the common case, and free.
|
||||||
|
if snap := a.awardSnapshotIfLoaded(); snap != nil {
|
||||||
|
for i := range snap {
|
||||||
|
collect(&snap[i])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Otherwise stream. One pass, nothing retained but the reference strings.
|
||||||
|
_ = a.qso.IterateForAwards(a.ctx, func(q qso.QSO) error {
|
||||||
|
a.enrichQSOForAwards(&q)
|
||||||
|
collect(&q)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// awardSnapshotIfLoaded returns the award snapshot ONLY if one is already built
|
||||||
|
// and current. Never builds one: the caller is a background convenience, and
|
||||||
|
// reading the whole logbook for it is the cost this file exists to avoid.
|
||||||
|
func (a *App) awardSnapshotIfLoaded() []qso.QSO {
|
||||||
|
rev, err := a.qso.Revision(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a.awardSnapMu.Lock()
|
||||||
|
defer a.awardSnapMu.Unlock()
|
||||||
|
if a.awardSnap != nil && a.awardSnapRev == rev {
|
||||||
|
return a.awardSnap
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteWorkedQSO folds a just-logged contact's references into the built sets, so
|
||||||
|
// logging does not throw away work already done.
|
||||||
|
func (a *App) noteWorkedQSO(q qso.QSO) {
|
||||||
|
a.workedRefMu.Lock()
|
||||||
|
codes := make(map[string]bool, len(a.workedRef.byCode))
|
||||||
|
for c := range a.workedRef.byCode {
|
||||||
|
codes[c] = true
|
||||||
|
}
|
||||||
|
a.workedRefMu.Unlock()
|
||||||
|
if len(codes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rev, err := a.qso.Revision(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.enrichQSOForAwards(&q)
|
||||||
|
found := map[string][]string{}
|
||||||
|
for _, d := range a.awardDefs() {
|
||||||
|
code := strings.ToUpper(d.Code)
|
||||||
|
if !codes[code] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
metas := a.awardRefMetas([]award.Def{d})[code]
|
||||||
|
for _, r := range award.MatchQSO(d, metas, &q) {
|
||||||
|
found[code] = append(found[code], strings.ToUpper(strings.TrimSpace(r)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.workedRefMu.Lock()
|
||||||
|
defer a.workedRefMu.Unlock()
|
||||||
|
// Carry the sets forward to the new revision rather than dropping them: they
|
||||||
|
// were correct one contact ago, and that contact is what is being added.
|
||||||
|
a.workedRef.rev = rev
|
||||||
|
for code, refs := range found {
|
||||||
|
if set := a.workedRef.byCode[code]; set != nil {
|
||||||
|
for _, r := range refs {
|
||||||
|
set[r] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidateWorkedRefs drops every set. For the changes a revision cannot see —
|
||||||
|
// award definitions edited, reference lists updated, QSLs confirmed.
|
||||||
|
func (a *App) invalidateWorkedRefs() {
|
||||||
|
a.workedRefMu.Lock()
|
||||||
|
a.workedRef = workedRefIndex{}
|
||||||
|
a.workedRefMu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSplitRefEntry(t *testing.T) {
|
||||||
|
code, ref, ok := splitRefEntry("rda@st-25")
|
||||||
|
if !ok || code != "RDA" || ref != "ST-25" {
|
||||||
|
t.Fatalf("got %q %q %v", code, ref, ok)
|
||||||
|
}
|
||||||
|
// The picker writes what the operator typed, spacing included.
|
||||||
|
if _, ref, _ = splitRefEntry("POTA@ fr-1234 "); ref != "FR-1234" {
|
||||||
|
t.Fatalf("not trimmed: %q", ref)
|
||||||
|
}
|
||||||
|
// A reference can contain the separator characters of a label; only the
|
||||||
|
// FIRST @ splits, so "IOTA@EU-064" survives and a stray one does not create
|
||||||
|
// an award out of nothing.
|
||||||
|
for _, bad := range []string{"", "RDA", "@ST-25", "RDA@", "RDA@ "} {
|
||||||
|
if _, _, ok := splitRefEntry(bad); ok {
|
||||||
|
t.Fatalf("%q accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/award"
|
||||||
|
)
|
||||||
|
|
||||||
|
// relabelOnly decides whether a save can take the cheap path. A wrong "yes"
|
||||||
|
// leaves stale references in the log, so every case that is not purely a
|
||||||
|
// display change must fall through to the full recompute.
|
||||||
|
func TestRelabelOnly(t *testing.T) {
|
||||||
|
base := []award.Def{
|
||||||
|
{Code: "DDFM", Field: "note", RefDisplay: "ref", Valid: true},
|
||||||
|
{Code: "IOTA", Field: "iota", RefDisplay: "", Valid: true},
|
||||||
|
}
|
||||||
|
cp := func() []award.Def { out := make([]award.Def, len(base)); copy(out, base); return out }
|
||||||
|
|
||||||
|
// The case this exists for: one award's column switches to the description.
|
||||||
|
next := cp()
|
||||||
|
next[0].RefDisplay = "name"
|
||||||
|
codes, ok := relabelOnly(base, next)
|
||||||
|
if !ok || len(codes) != 1 || codes[0] != "DDFM" {
|
||||||
|
t.Errorf("display-only change = (%v, %v), want ([DDFM], true)", codes, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two at once is still a relabel.
|
||||||
|
next = cp()
|
||||||
|
next[0].RefDisplay = "both"
|
||||||
|
next[1].RefDisplay = "name"
|
||||||
|
if codes, ok := relabelOnly(base, next); !ok || len(codes) != 2 {
|
||||||
|
t.Errorf("two display changes = (%v, %v), want two codes", codes, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing changed: no relabel to do, and no full recompute claimed either.
|
||||||
|
if codes, ok := relabelOnly(base, cp()); ok || codes != nil {
|
||||||
|
t.Errorf("identical definitions must not report a relabel, got (%v, %v)", codes, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything that can change WHICH QSOs match must fail the test.
|
||||||
|
for name, mutate := range map[string]func([]award.Def){
|
||||||
|
"field": func(d []award.Def) { d[0].Field = "comment" },
|
||||||
|
"pattern": func(d []award.Def) { d[0].Pattern = `\d{2}` },
|
||||||
|
"exact": func(d []award.Def) { d[0].ExactMatch = true },
|
||||||
|
"validity": func(d []award.Def) { d[0].ValidFrom = "2020-01-01" },
|
||||||
|
"dxcc": func(d []award.Def) { d[0].DXCCFilter = []int{227} },
|
||||||
|
"disabled": func(d []award.Def) { d[0].Valid = false },
|
||||||
|
"or rule": func(d []award.Def) { d[0].OrRules = []award.OrRule{{Field: "qth"}} },
|
||||||
|
"with disp": func(d []award.Def) { d[0].RefDisplay = "name"; d[0].Field = "comment" },
|
||||||
|
} {
|
||||||
|
n := cp()
|
||||||
|
mutate(n)
|
||||||
|
if _, ok := relabelOnly(base, n); ok {
|
||||||
|
t.Errorf("%s: took the relabel path — stale references would stay in the log", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An award added or removed changes the set, never a relabel.
|
||||||
|
if _, ok := relabelOnly(base, append(cp(), award.Def{Code: "WWFF"})); ok {
|
||||||
|
t.Errorf("an added award must force a full recompute")
|
||||||
|
}
|
||||||
|
if _, ok := relabelOnly(base, cp()[:1]); ok {
|
||||||
|
t.Errorf("a removed award must force a full recompute")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The award snapshot must be built ONCE however many callers ask at once.
|
||||||
|
//
|
||||||
|
// The cache lock is released before the logbook is read, so every caller that
|
||||||
|
// arrives during a build used to miss the cache and start its own. Opening the
|
||||||
|
// Awards panel does exactly that: a field log showed three pulls of the same
|
||||||
|
// 123 615 QSOs inside ten seconds, and three copies alive together took the Go
|
||||||
|
// heap from 725 MB to 2.5 GB.
|
||||||
|
//
|
||||||
|
// This models the same shape — a cheap cache check, a slow build, a shared
|
||||||
|
// result — against the pattern awardSnapshot now uses, so the invariant is
|
||||||
|
// pinned without needing a logbook.
|
||||||
|
func TestSnapshotBuildsOncePerRevision(t *testing.T) {
|
||||||
|
var (
|
||||||
|
cacheMu sync.Mutex
|
||||||
|
buildMu sync.Mutex
|
||||||
|
cached []int
|
||||||
|
rev = "r1"
|
||||||
|
gotRev string
|
||||||
|
builds int
|
||||||
|
)
|
||||||
|
|
||||||
|
get := func() []int {
|
||||||
|
cacheMu.Lock()
|
||||||
|
if cached != nil && gotRev == rev {
|
||||||
|
defer cacheMu.Unlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
cacheMu.Unlock()
|
||||||
|
|
||||||
|
buildMu.Lock()
|
||||||
|
defer buildMu.Unlock()
|
||||||
|
// Re-check: whoever held the build lock has just finished.
|
||||||
|
cacheMu.Lock()
|
||||||
|
if cached != nil && gotRev == rev {
|
||||||
|
defer cacheMu.Unlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
cacheMu.Unlock()
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond) // the logbook read
|
||||||
|
out := []int{1, 2, 3}
|
||||||
|
|
||||||
|
cacheMu.Lock()
|
||||||
|
builds++
|
||||||
|
cached, gotRev = out, rev
|
||||||
|
cacheMu.Unlock()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([][]int, 8)
|
||||||
|
for i := range results {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) { defer wg.Done(); results[i] = get() }(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if builds != 1 {
|
||||||
|
t.Errorf("%d builds for one revision — each concurrent caller pulled the whole logbook again", builds)
|
||||||
|
}
|
||||||
|
for i, r := range results {
|
||||||
|
if len(r) != 3 {
|
||||||
|
t.Errorf("caller %d got %v", i, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new revision must rebuild: the guard is against duplicate work, not
|
||||||
|
// against a logbook that changed.
|
||||||
|
cacheMu.Lock()
|
||||||
|
rev = "r2"
|
||||||
|
cacheMu.Unlock()
|
||||||
|
get()
|
||||||
|
if builds != 2 {
|
||||||
|
t.Errorf("builds = %d after the revision moved, want 2 — a changed logbook must be re-read", builds)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A band change now has two sources: the rig, and the entry strip on a station
|
||||||
|
// whose rig OpsLog does not control. They share one memory of the last band, so
|
||||||
|
// a station that has both commands its antenna switch once per QSY rather than
|
||||||
|
// twice — the entry selector pushes a QSY, the rig reports the same band back,
|
||||||
|
// and only the first of the two is a change.
|
||||||
|
func TestBandChangeIsNotedOncePerBand(t *testing.T) {
|
||||||
|
lastTriggerBandMu.Lock()
|
||||||
|
lastTriggerBand = ""
|
||||||
|
lastTriggerBandMu.Unlock()
|
||||||
|
|
||||||
|
if b, changed := noteBandChange("20m"); !changed || b != "20m" {
|
||||||
|
t.Fatalf("first 20m = (%q,%v), want (\"20m\",true)", b, changed)
|
||||||
|
}
|
||||||
|
// The same band from the other source — the rig echoing the QSY back.
|
||||||
|
if _, changed := noteBandChange("20M"); changed {
|
||||||
|
t.Error("the rig echoing the band back counted as a second change — the switch would be commanded twice")
|
||||||
|
}
|
||||||
|
if _, changed := noteBandChange(" 20m "); changed {
|
||||||
|
t.Error("whitespace made the same band look new")
|
||||||
|
}
|
||||||
|
if b, changed := noteBandChange("40m"); !changed || b != "40m" {
|
||||||
|
t.Errorf("40m = (%q,%v), want (\"40m\",true)", b, changed)
|
||||||
|
}
|
||||||
|
// An unknown band must not be recorded, or the next real one would look
|
||||||
|
// unchanged against it.
|
||||||
|
if _, changed := noteBandChange(""); changed {
|
||||||
|
t.Error("an empty band was treated as a change")
|
||||||
|
}
|
||||||
|
if _, changed := noteBandChange("40m"); changed {
|
||||||
|
t.Error("the empty band overwrote the last one")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/pskr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The receiver radius is what makes an opening report evidence about the
|
||||||
|
// OPERATOR's path rather than someone else's. Both ends of the range destroy
|
||||||
|
// that: too small and no receiver is ever near enough, too large and the
|
||||||
|
// reports are about a different part of the world. A stored value that would do
|
||||||
|
// either has to fall back rather than be honoured.
|
||||||
|
func TestBandOpenNearKmClamps(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"unset falls back", "", pskr.DefaultNearKm},
|
||||||
|
{"not a number falls back", "soon", pskr.DefaultNearKm},
|
||||||
|
{"zero falls back", "0", pskr.DefaultNearKm},
|
||||||
|
{"negative falls back", "-100", pskr.DefaultNearKm},
|
||||||
|
{"absurdly tight falls back", "5", pskr.DefaultNearKm},
|
||||||
|
{"absurdly wide falls back", "5000", pskr.DefaultNearKm},
|
||||||
|
{"a real choice is kept", "100", 100},
|
||||||
|
{"the lower bound is kept", "25", 25},
|
||||||
|
{"the upper bound is kept", "1000", 1000},
|
||||||
|
{"surrounding space is ignored", " 150 ", 150},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := bandOpenNearKm(tc.raw); got != tc.want {
|
||||||
|
t.Errorf("bandOpenNearKm(%q) = %d, want %d", tc.raw, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
-7
@@ -16,17 +16,21 @@ package main
|
|||||||
// they no longer need.
|
// they no longer need.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
"hamlog/internal/bandopen"
|
"hamlog/internal/bandopen"
|
||||||
"hamlog/internal/cluster"
|
"hamlog/internal/cluster"
|
||||||
|
"hamlog/internal/geo"
|
||||||
|
"hamlog/internal/gridcache"
|
||||||
"hamlog/internal/pskr"
|
"hamlog/internal/pskr"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
keyBandOpenEnabled = "bandopen.enabled"
|
keyBandOpenEnabled = "bandopen.enabled"
|
||||||
keyBandOpenBands = "bandopen.bands" // comma-separated; empty = the default set
|
keyBandOpenBands = "bandopen.bands" // comma-separated; empty = the default set
|
||||||
|
keyBandOpenNearKm = "bandopen.nearkm" // receiver radius; empty = pskr.DefaultNearKm
|
||||||
)
|
)
|
||||||
|
|
||||||
// rbnNodes are the two Reverse Beacon Network endpoints the watch wants: CW and
|
// rbnNodes are the two Reverse Beacon Network endpoints the watch wants: CW and
|
||||||
@@ -43,6 +47,18 @@ type BandOpenSettings struct {
|
|||||||
// Available is every band that can be watched, so the UI does not carry its
|
// Available is every band that can be watched, so the UI does not carry its
|
||||||
// own copy of a list that belongs to the detector.
|
// own copy of a list that belongs to the detector.
|
||||||
Available []string `json:"available"`
|
Available []string `json:"available"`
|
||||||
|
// NearKm is how close a RECEIVER has to be for its report to count as
|
||||||
|
// evidence about the operator's own path. An opening on these bands reaches
|
||||||
|
// an AREA, and the right radius is a judgement about how big that area is —
|
||||||
|
// a 2 m tropo duct is not a 10 m Es footprint — so it is the operator's to
|
||||||
|
// make rather than a constant.
|
||||||
|
//
|
||||||
|
// Tightening it costs nothing in traffic: the broker subscription is one
|
||||||
|
// ring of squares whatever this says, and this only decides what survives
|
||||||
|
// once the messages are here. It costs SENSITIVITY — too small a radius in a
|
||||||
|
// thinly-populated region leaves no receivers at all, and a watch with
|
||||||
|
// nothing to look at reports nothing while appearing to work.
|
||||||
|
NearKm int `json:"near_km"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetBandOpenSettings() BandOpenSettings {
|
func (a *App) GetBandOpenSettings() BandOpenSettings {
|
||||||
@@ -50,6 +66,7 @@ func (a *App) GetBandOpenSettings() BandOpenSettings {
|
|||||||
Enabled: a.settingOr(keyBandOpenEnabled, "") == "1",
|
Enabled: a.settingOr(keyBandOpenEnabled, "") == "1",
|
||||||
Bands: splitCSV(a.settingOr(keyBandOpenBands, "")),
|
Bands: splitCSV(a.settingOr(keyBandOpenBands, "")),
|
||||||
Available: pskr.Bands,
|
Available: pskr.Bands,
|
||||||
|
NearKm: bandOpenNearKm(a.settingOr(keyBandOpenNearKm, "")),
|
||||||
}
|
}
|
||||||
// Keep only bands that are still offered. A saved selection outlives the code
|
// Keep only bands that are still offered. A saved selection outlives the code
|
||||||
// that made it: 12 m was dropped from the watched set, but every operator who
|
// that made it: 12 m was dropped from the watched set, but every operator who
|
||||||
@@ -76,9 +93,36 @@ func keepKnownBands(want []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pskrMaxGrids bounds the receiver squares the broker is asked to filter on.
|
||||||
|
// Each is one subscription, and the traffic follows the area: 2000 km is about
|
||||||
|
// 615 squares, which the broker takes in batches without complaint. The cap is
|
||||||
|
// there so an absurd radius cannot turn into thousands of subscriptions — past
|
||||||
|
// it the nearest squares are kept, and the log says how many.
|
||||||
|
const pskrMaxGrids = 900
|
||||||
|
|
||||||
|
// bandOpenNearKm reads the stored receiver radius, falling back to the default
|
||||||
|
// for anything unset or out of range. The bounds are the two ways to make the
|
||||||
|
// watch useless: below 25 km almost nobody is ever near enough to hear anything,
|
||||||
|
// and past 3000 km a "nearby" receiver is on the far side of a continent, which
|
||||||
|
// says nothing about the operator's own path.
|
||||||
|
//
|
||||||
|
// The ceiling was 1000 km, chosen with Europe in mind, where that radius holds a
|
||||||
|
// dozen countries' worth of receivers. It is the wrong number in VK: a station
|
||||||
|
// there can have almost nobody inside 300 km, and the chase list stayed empty
|
||||||
|
// not because nothing was on the air but because nothing was listening close
|
||||||
|
// enough to count.
|
||||||
|
func bandOpenNearKm(raw string) int {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
|
if err != nil || n < 25 || n > 3000 {
|
||||||
|
return pskr.DefaultNearKm
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) SaveBandOpenSettings(s BandOpenSettings) error {
|
func (a *App) SaveBandOpenSettings(s BandOpenSettings) error {
|
||||||
a.setSetting(keyBandOpenEnabled, map[bool]string{true: "1", false: "0"}[s.Enabled])
|
a.setSetting(keyBandOpenEnabled, map[bool]string{true: "1", false: "0"}[s.Enabled])
|
||||||
a.setSetting(keyBandOpenBands, strings.Join(s.Bands, ","))
|
a.setSetting(keyBandOpenBands, strings.Join(s.Bands, ","))
|
||||||
|
a.setSetting(keyBandOpenNearKm, strconv.Itoa(bandOpenNearKm(strconv.Itoa(s.NearKm))))
|
||||||
if s.Enabled {
|
if s.Enabled {
|
||||||
a.ensureRBNNodes()
|
a.ensureRBNNodes()
|
||||||
}
|
}
|
||||||
@@ -126,19 +170,74 @@ func (a *App) startBandOpenFeed() {
|
|||||||
a.pskr = nil
|
a.pskr = nil
|
||||||
}
|
}
|
||||||
s := a.GetBandOpenSettings()
|
s := a.GetBandOpenSettings()
|
||||||
|
a.bandOpen.on.Store(s.Enabled)
|
||||||
|
a.setBandOpenBands(s.Bands)
|
||||||
if !s.Enabled {
|
if !s.Enabled {
|
||||||
|
// Put out whatever is currently lit. Leaving the badges up would keep
|
||||||
|
// announcing an opening from a watch that is now off, and they only fade
|
||||||
|
// on a timer fed by spots this path no longer looks at — so they would
|
||||||
|
// hang there until the app restarted.
|
||||||
|
a.clearBandOpenings()
|
||||||
|
}
|
||||||
|
chaseGrids := a.gridStore != nil
|
||||||
|
chaseNew := a.chaseNewEnabled()
|
||||||
|
// Three consumers, one feed. Any one of them is reason enough to bring it up,
|
||||||
|
// and turning one off must not cut the others loose.
|
||||||
|
if !s.Enabled && !chaseGrids && !chaseNew {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Every spot is measured from the operator's position. Without one there is
|
// Every spot is measured from the operator's position. Without one there is
|
||||||
// nothing to measure, and a detector fed unmeasurable spots reports nothing
|
// nothing to measure, and a detector fed unmeasurable spots reports nothing
|
||||||
// while looking like it is working.
|
// while looking like it is working.
|
||||||
if !a.opSet {
|
if !a.opSet {
|
||||||
applog.Printf("bandopen: no station grid set — the opening watch needs one to measure a path")
|
applog.Printf("pskr: no station grid set — the feed needs one to measure a path")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grid chasing and new-chasing want every band; the opening watch wants its
|
||||||
|
// four. "+" is the MQTT single-level wildcard, so one subscription per square
|
||||||
|
// covers the lot.
|
||||||
|
bands := s.Bands
|
||||||
|
if chaseGrids || chaseNew {
|
||||||
|
bands = []string{"+"}
|
||||||
|
}
|
||||||
|
// Filter at the BROKER on the receiver's square rather than receiving the
|
||||||
|
// world and discarding it here. Measured on the live feed: the four opening
|
||||||
|
// bands unfiltered are 83 messages a second, of which roughly one in a
|
||||||
|
// hundred survived the NearKm test below. One ring of squares — about the
|
||||||
|
// same 300 km — is 0.2 to 1.2 a second, and the same for every operator,
|
||||||
|
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K).
|
||||||
|
//
|
||||||
|
// The set follows the radius the operator asked for. It was one fixed ring
|
||||||
|
// whatever they set — about 300 km — so raising the radius bought nothing:
|
||||||
|
// the reports that would have satisfied it were never sent to us in the
|
||||||
|
// first place, and an operator in a thinly-populated region who widened the
|
||||||
|
// circle to find some receivers saw no change at all.
|
||||||
|
rxGrids := geo.GridsWithin(a.opLat, a.opLon, float64(s.NearKm), pskrMaxGrids)
|
||||||
|
if len(rxGrids) == 0 {
|
||||||
|
rxGrids = geo.NeighbourGrids(a.opLat, a.opLon, 1)
|
||||||
|
}
|
||||||
|
applog.Printf("pskr: receiver filter — %d squares within %d km of the station", len(rxGrids), s.NearKm)
|
||||||
|
|
||||||
|
var onGrid func(call, grid string)
|
||||||
|
if chaseGrids {
|
||||||
|
onGrid = func(call, grid string) { a.rememberDecodeGrid(call, grid, gridcache.SourceMQTT) }
|
||||||
|
}
|
||||||
|
var onSpot func(pskr.Spot)
|
||||||
|
switch {
|
||||||
|
case s.Enabled && chaseNew:
|
||||||
|
onSpot = func(sp pskr.Spot) { a.feedBandOpen(sp); a.feedChaseNew(sp) }
|
||||||
|
case s.Enabled:
|
||||||
|
onSpot = a.feedBandOpen
|
||||||
|
case chaseNew:
|
||||||
|
onSpot = a.feedChaseNew
|
||||||
|
}
|
||||||
|
|
||||||
a.pskr = pskr.New(pskr.Config{
|
a.pskr = pskr.New(pskr.Config{
|
||||||
Bands: s.Bands,
|
Bands: bands,
|
||||||
OpLat: a.opLat, OpLon: a.opLon,
|
RxGrids: rxGrids,
|
||||||
|
NearKm: s.NearKm,
|
||||||
|
OpLat: a.opLat, OpLon: a.opLon,
|
||||||
Geo: func(grid string) (int, int, bool) {
|
Geo: func(grid string) (int, int, bool) {
|
||||||
lat, lon, ok := gridToLatLon(grid)
|
lat, lon, ok := gridToLatLon(grid)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -150,12 +249,16 @@ func (a *App) startBandOpenFeed() {
|
|||||||
b := int(initialBearingDeg(a.opLat, a.opLon, lat, lon) + 0.5)
|
b := int(initialBearingDeg(a.opLat, a.opLon, lat, lon) + 0.5)
|
||||||
return d, b, true
|
return d, b, true
|
||||||
},
|
},
|
||||||
OnSpot: a.feedBandOpen,
|
OnSpot: onSpot,
|
||||||
|
OnGrid: onGrid,
|
||||||
Logf: applog.Printf,
|
Logf: applog.Printf,
|
||||||
})
|
})
|
||||||
if err := a.pskr.Start(); err != nil {
|
if err := a.pskr.Start(); err != nil {
|
||||||
applog.Printf("bandopen: PSK Reporter feed did not start: %v", err)
|
applog.Printf("pskr: feed did not start: %v", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
applog.Printf("pskr: feed up — bands %v, %d receiver squares (openings=%v, grids=%v, chase-new=%v)",
|
||||||
|
bands, len(rxGrids), s.Enabled, chaseGrids, chaseNew)
|
||||||
}
|
}
|
||||||
|
|
||||||
// feedBandOpen hands one PSK Reporter decode to the detector.
|
// feedBandOpen hands one PSK Reporter decode to the detector.
|
||||||
@@ -164,7 +267,7 @@ func (a *App) startBandOpenFeed() {
|
|||||||
// so it does the least possible: the detector's own window and de-duplication
|
// so it does the least possible: the detector's own window and de-duplication
|
||||||
// by callsign are what turn that flood into one announcement.
|
// by callsign are what turn that flood into one announcement.
|
||||||
func (a *App) feedBandOpen(s pskr.Spot) {
|
func (a *App) feedBandOpen(s pskr.Spot) {
|
||||||
if !bandopen.Watched(s.Band) {
|
if !a.bandOpenWanted(s.Band) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.bandOpen.mu.Lock()
|
a.bandOpen.mu.Lock()
|
||||||
|
|||||||
+76
-1
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
@@ -21,7 +22,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type bandOpenState struct {
|
type bandOpenState struct {
|
||||||
mu sync.Mutex
|
// on mirrors the "Watch for band openings" setting.
|
||||||
|
//
|
||||||
|
// Cached rather than read per spot: this is the cluster hot path, where a
|
||||||
|
// settings query per spot is exactly what the rest of this file avoids.
|
||||||
|
// startBandOpenFeed owns it — it runs at startup and again on every save, so
|
||||||
|
// the switch takes effect without a restart.
|
||||||
|
on atomic.Bool
|
||||||
|
// bands is the operator's SELECTED set, held as map[string]bool.
|
||||||
|
//
|
||||||
|
// bandopen.Watched only says which bands the detector is capable of; it
|
||||||
|
// knows nothing about the chips in Settings. Nothing checked the selection
|
||||||
|
// on either feed path, so unticking 10 m and 2 m changed the subscription and
|
||||||
|
// left the cluster path announcing them anyway.
|
||||||
|
bands atomic.Value
|
||||||
|
mu sync.Mutex
|
||||||
det *bandopen.Detector
|
det *bandopen.Detector
|
||||||
last []bandopen.Opening // most recent first, for the UI
|
last []bandopen.Opening // most recent first, for the UI
|
||||||
// live holds the announced openings that are still going, keyed by band, and
|
// live holds the announced openings that are still going, keyed by band, and
|
||||||
@@ -45,6 +60,16 @@ const maxRememberedOpenings = 20
|
|||||||
|
|
||||||
// detectBandOpening feeds one spot to the detector and announces a hit.
|
// detectBandOpening feeds one spot to the detector and announces a hit.
|
||||||
func (a *App) detectBandOpening(s cluster.Spot) {
|
func (a *App) detectBandOpening(s cluster.Spot) {
|
||||||
|
// The watch has to be switched on.
|
||||||
|
//
|
||||||
|
// It was not checked here at all: the setting only ever governed the extra
|
||||||
|
// DATA SOURCES (the RBN nodes and the PSK Reporter feed), while the detector
|
||||||
|
// itself ran on every ordinary cluster spot. So an operator who had never
|
||||||
|
// enabled the watch still got opening banners, from a feature they had
|
||||||
|
// deliberately left off.
|
||||||
|
if !a.bandOpen.on.Load() || !a.bandOpenWanted(s.Band) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// No operator grid = no distance and no bearing on the spot, and the whole
|
// No operator grid = no distance and no bearing on the spot, and the whole
|
||||||
// detection rests on those two. Say nothing rather than guess.
|
// detection rests on those two. Say nothing rather than guess.
|
||||||
if !a.opSet || s.DistanceKm <= 0 || !bandopen.Watched(s.Band) {
|
if !a.opSet || s.DistanceKm <= 0 || !bandopen.Watched(s.Band) {
|
||||||
@@ -122,6 +147,56 @@ func (a *App) GetLiveOpenings() []bandopen.Opening {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bandOpenWanted reports whether the operator has this band ticked. Falls back
|
||||||
|
// to the detector's own set until a selection has been stored, so a band is
|
||||||
|
// never silently dropped before the settings have been read.
|
||||||
|
func (a *App) bandOpenWanted(band string) bool {
|
||||||
|
if !bandopen.Watched(band) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sel, ok := a.bandOpen.bands.Load().(map[string]bool)
|
||||||
|
if !ok || len(sel) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return sel[strings.ToLower(strings.TrimSpace(band))]
|
||||||
|
}
|
||||||
|
|
||||||
|
// setBandOpenBands records the selection and puts out badges for bands that
|
||||||
|
// have just been unticked — they fade on a timer fed by spots the detector no
|
||||||
|
// longer looks at, so they would otherwise stay lit until the next restart.
|
||||||
|
func (a *App) setBandOpenBands(bands []string) {
|
||||||
|
sel := make(map[string]bool, len(bands))
|
||||||
|
for _, b := range bands {
|
||||||
|
sel[strings.ToLower(strings.TrimSpace(b))] = true
|
||||||
|
}
|
||||||
|
a.bandOpen.bands.Store(sel)
|
||||||
|
|
||||||
|
a.bandOpen.mu.Lock()
|
||||||
|
defer a.bandOpen.mu.Unlock()
|
||||||
|
for b := range a.bandOpen.live {
|
||||||
|
if len(sel) > 0 && !sel[b] {
|
||||||
|
delete(a.bandOpen.live, b)
|
||||||
|
delete(a.bandOpen.aliveUntil, b)
|
||||||
|
applog.Printf("bandopen: %s is no longer watched — badge cleared", strings.ToUpper(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearBandOpenings puts out every lit badge and forgets the detector's window.
|
||||||
|
// Called when the watch is switched off: the badges fade on a timer fed by
|
||||||
|
// spots the detector no longer looks at, so without this they would stay up
|
||||||
|
// until the next restart. The remembered list is left alone — those openings
|
||||||
|
// really did happen, and the operator may still want to see what he missed.
|
||||||
|
func (a *App) clearBandOpenings() {
|
||||||
|
a.bandOpen.mu.Lock()
|
||||||
|
defer a.bandOpen.mu.Unlock()
|
||||||
|
a.bandOpen.live = nil
|
||||||
|
a.bandOpen.aliveUntil = nil
|
||||||
|
// Drop the accumulated spot window too, so switching the watch back on starts
|
||||||
|
// from what is on the air now rather than from an hour-old burst.
|
||||||
|
a.bandOpen.det = nil
|
||||||
|
}
|
||||||
|
|
||||||
// announceOpening logs and pushes one detection. Shared by both feeds — the
|
// announceOpening logs and pushes one detection. Shared by both feeds — the
|
||||||
// cluster path here and the PSK Reporter path in bandopen_sources.go — so an
|
// cluster path here and the PSK Reporter path in bandopen_sources.go — so an
|
||||||
// opening reads the same however it was noticed.
|
// opening reads the same however it was noticed.
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/bandopen"
|
||||||
|
"hamlog/internal/cluster"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The "Watch for band openings" switch has to gate the DETECTOR, not just the
|
||||||
|
// extra data sources.
|
||||||
|
//
|
||||||
|
// It originally governed only the RBN nodes and the PSK Reporter feed, while
|
||||||
|
// the detector itself ran on every ordinary cluster spot — so an operator who
|
||||||
|
// had never enabled the watch still got opening banners for a feature he had
|
||||||
|
// deliberately left off. That is what this pins.
|
||||||
|
func TestBandOpenWatchGatesTheDetector(t *testing.T) {
|
||||||
|
spot := func() cluster.Spot {
|
||||||
|
return cluster.Spot{
|
||||||
|
DXCall: "EA1ABC", Band: "6m", DistanceKm: 1400, ShortPath: 210,
|
||||||
|
ReceivedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switched off: the spot must not even reach the detector.
|
||||||
|
off := &App{opSet: true, opLat: 48.0, opLon: 2.0}
|
||||||
|
off.detectBandOpening(spot())
|
||||||
|
if off.bandOpen.det != nil {
|
||||||
|
t.Error("the detector ran with the watch switched off")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switched on: the same spot is accepted (one spot is not an opening, so
|
||||||
|
// nothing is announced — but the detector now exists and is collecting).
|
||||||
|
on := &App{opSet: true, opLat: 48.0, opLon: 2.0}
|
||||||
|
on.bandOpen.on.Store(true)
|
||||||
|
on.detectBandOpening(spot())
|
||||||
|
if on.bandOpen.det == nil {
|
||||||
|
t.Error("the detector did not run with the watch switched on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switching the watch off must put the badges out. They fade on a timer fed by
|
||||||
|
// spots the detector no longer looks at, so left alone they would stay lit
|
||||||
|
// until the next restart.
|
||||||
|
func TestClearBandOpeningsPutsTheBadgesOut(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.bandOpen.live = map[string]bandopen.Opening{"6m": {Band: "6m", Calls: 9}}
|
||||||
|
a.bandOpen.aliveUntil = map[string]time.Time{"6m": time.Now().Add(time.Hour)}
|
||||||
|
a.bandOpen.det = bandopen.New(bandopen.DefaultConfig())
|
||||||
|
a.bandOpen.last = []bandopen.Opening{{Band: "6m", Calls: 9}}
|
||||||
|
|
||||||
|
a.clearBandOpenings()
|
||||||
|
|
||||||
|
if got := a.GetLiveOpenings(); len(got) != 0 {
|
||||||
|
t.Errorf("a badge stayed lit after the watch was switched off: %v", got)
|
||||||
|
}
|
||||||
|
if a.bandOpen.det != nil {
|
||||||
|
t.Error("the accumulated spot window survived — switching back on would start from a stale burst")
|
||||||
|
}
|
||||||
|
// The history is NOT cleared: those openings really happened.
|
||||||
|
if len(a.GetBandOpenings()) != 1 {
|
||||||
|
t.Error("the remembered openings were thrown away")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unticking a band in Settings must actually stop its announcements.
|
||||||
|
//
|
||||||
|
// Nothing checked the operator's selection on either feed path: bandopen.Watched
|
||||||
|
// only says which bands the detector is CAPABLE of. So a station with just 6 m
|
||||||
|
// ticked still got 10 m and 2 m badges, from the cluster path and — once grid
|
||||||
|
// chasing widened the subscription to every band — from PSK Reporter too.
|
||||||
|
func TestBandOpenHonoursTheSelectedBands(t *testing.T) {
|
||||||
|
a := &App{opSet: true, opLat: 48, opLon: 2}
|
||||||
|
a.bandOpen.on.Store(true)
|
||||||
|
a.setBandOpenBands([]string{"6m"})
|
||||||
|
|
||||||
|
if !a.bandOpenWanted("6m") {
|
||||||
|
t.Error("6m is ticked and was refused")
|
||||||
|
}
|
||||||
|
for _, b := range []string{"10m", "2m", "4m"} {
|
||||||
|
if a.bandOpenWanted(b) {
|
||||||
|
t.Errorf("%s is not ticked and was accepted", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A band the detector cannot watch at all stays out whatever is stored.
|
||||||
|
if a.bandOpenWanted("20m") {
|
||||||
|
t.Error("20m is not a watched band and was accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the cluster path must obey it too.
|
||||||
|
a.detectBandOpening(cluster.Spot{
|
||||||
|
DXCall: "K1ABC", Band: "10m", DistanceKm: 6000, ShortPath: 280, ReceivedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if a.bandOpen.det != nil {
|
||||||
|
t.Error("a spot on an unticked band reached the detector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unticking a band puts its badge out. They fade on a timer fed by spots the
|
||||||
|
// detector no longer looks at, so it would otherwise stay lit until a restart.
|
||||||
|
func TestUntickingABandClearsItsBadge(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.bandOpen.live = map[string]bandopen.Opening{
|
||||||
|
"6m": {Band: "6m", Calls: 9},
|
||||||
|
"10m": {Band: "10m", Calls: 5},
|
||||||
|
}
|
||||||
|
a.bandOpen.aliveUntil = map[string]time.Time{
|
||||||
|
"6m": time.Now().Add(time.Hour),
|
||||||
|
"10m": time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
a.setBandOpenBands([]string{"6m"})
|
||||||
|
|
||||||
|
live := a.GetLiveOpenings()
|
||||||
|
if len(live) != 1 || live[0].Band != "6m" {
|
||||||
|
t.Errorf("live openings = %v, want only 6m", live)
|
||||||
|
}
|
||||||
|
}
|
||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The first breadcrumbs, written before anything else can fail.
|
||||||
|
//
|
||||||
|
// OpsLog keeps its log in the data folder, which lives beside the executable —
|
||||||
|
// so every fault that happens BEFORE that folder exists is invisible. Reported
|
||||||
|
// from a Windows 10 machine: "the process appears, no data folder is created,
|
||||||
|
// nothing starts", with no file anywhere to say why. There was nothing to read
|
||||||
|
// because the only place we write to had not been created yet.
|
||||||
|
//
|
||||||
|
// This writes to %LOCALAPPDATA%\OpsLog\startup.log instead: a folder Windows
|
||||||
|
// guarantees is writable for the user, whatever OpsLog itself was installed
|
||||||
|
// into. It records the handful of milestones between the process starting and
|
||||||
|
// the window appearing, and nothing else — it is not a second log, it is the
|
||||||
|
// answer to "it does not start".
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bootLogPath is the file, or "" when even LOCALAPPDATA is unavailable.
|
||||||
|
func bootLogPath() string {
|
||||||
|
dir := os.Getenv("LOCALAPPDATA")
|
||||||
|
if strings.TrimSpace(dir) == "" {
|
||||||
|
dir = os.TempDir()
|
||||||
|
}
|
||||||
|
if dir == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
dir = filepath.Join(dir, "OpsLog")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "startup.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootLog appends one line. Never fails loudly: it exists to explain a failure,
|
||||||
|
// so it must not become one.
|
||||||
|
func bootLog(format string, args ...any) {
|
||||||
|
p := bootLogPath()
|
||||||
|
if p == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
// Trimmed when it gets long. A startup log that grows for two years is a
|
||||||
|
// file nobody opens, and the interesting launch is always the last one.
|
||||||
|
if fi, err := f.Stat(); err == nil && fi.Size() > 256*1024 {
|
||||||
|
f.Close()
|
||||||
|
_ = os.Remove(p)
|
||||||
|
f, err = os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(f, "%s %s\n", time.Now().Format("2006-01-02 15:04:05.000"), fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootLogLaunch records what was launched and from where.
|
||||||
|
func bootLogLaunch() {
|
||||||
|
exe, _ := os.Executable()
|
||||||
|
bootLog("launch: %s %v", exe, os.Args[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDataDirWritable makes sure the folder OpsLog keeps everything in can
|
||||||
|
// actually be created and written to, and says exactly what failed if not.
|
||||||
|
//
|
||||||
|
// The data folder sits beside the executable, which is fine on a stick or in a
|
||||||
|
// home directory and refused outright under Program Files — where Windows
|
||||||
|
// silently denies the write to anything not elevated. That refusal used to end
|
||||||
|
// the launch with no window and no message.
|
||||||
|
func checkDataDirWritable() error {
|
||||||
|
dir, err := userDataDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot work out where to keep the data: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("cannot create the data folder %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
probe := filepath.Join(dir, ".writetest")
|
||||||
|
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("the data folder %s cannot be written to: %w", dir, err)
|
||||||
|
}
|
||||||
|
_ = os.Remove(probe)
|
||||||
|
bootLog("data dir ok: %s", dir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startupReached flips as soon as OnStartup runs — the first moment OpsLog's
|
||||||
|
// own code is executing inside the window's lifecycle.
|
||||||
|
var startupReached atomic.Bool
|
||||||
|
|
||||||
|
// startupStuckMessage is what an operator sees when the window never starts.
|
||||||
|
//
|
||||||
|
// It names the two causes and what to do about each, because "OpsLog is not
|
||||||
|
// responding" sends people to reinstall OpsLog, which is the one thing that
|
||||||
|
// cannot help: the part that has not started is not ours.
|
||||||
|
const startupStuckMessage = "OpsLog started but its window never opened.\n\n" +
|
||||||
|
"This is the WebView2 runtime failing to start, and it is almost always one of two things:\n\n" +
|
||||||
|
"• The Microsoft Edge WebView2 Runtime is missing — install it from Microsoft, then start OpsLog again.\n" +
|
||||||
|
"• An antivirus is blocking msedgewebview2.exe — add OpsLog's folder to its exceptions.\n\n" +
|
||||||
|
"The details are in the OpsLog folder under LOCALAPPDATA (startup.log)."
|
||||||
|
|
||||||
|
// webviewDataPath is where WebView2 keeps its profile.
|
||||||
|
//
|
||||||
|
// Named rather than left to the default, which lands in the ROAMING profile: on
|
||||||
|
// a managed account that folder can be redirected to a network share, and a
|
||||||
|
// WebView2 profile on a share that is slow or unreachable does not fail — it
|
||||||
|
// hangs, which is indistinguishable from a runtime that will not start.
|
||||||
|
//
|
||||||
|
// It also gives the folder a name someone can be told to delete: a corrupt
|
||||||
|
// profile is a real cause of this, and "delete this folder and try again" is
|
||||||
|
// only advice if the folder can be pointed at.
|
||||||
|
func webviewDataPath() string {
|
||||||
|
dir := os.Getenv("LOCALAPPDATA")
|
||||||
|
if strings.TrimSpace(dir) == "" {
|
||||||
|
return "" // let Wails decide; there is nothing better to offer
|
||||||
|
}
|
||||||
|
p := filepath.Join(dir, "OpsLog", "WebView2")
|
||||||
|
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||||
|
bootLog("WebView2 profile folder %s could not be created: %v — leaving it to Wails", p, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// stuckMarkerPath is written before the window is attempted and removed once it
|
||||||
|
// opens, so the NEXT launch can tell that the last one never got there.
|
||||||
|
func stuckMarkerPath() string {
|
||||||
|
dir := os.Getenv("LOCALAPPDATA")
|
||||||
|
if strings.TrimSpace(dir) == "" {
|
||||||
|
dir = os.TempDir()
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "OpsLog", ".launching")
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastLaunchHung is set at startup from the marker left by the previous run.
|
||||||
|
var lastLaunchHung bool
|
||||||
|
|
||||||
|
// noteLaunchAttempt records that a window is about to be attempted, and reports
|
||||||
|
// whether the previous attempt ever finished.
|
||||||
|
func noteLaunchAttempt() {
|
||||||
|
p := stuckMarkerPath()
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
lastLaunchHung = true
|
||||||
|
bootLog("the previous launch never opened its window — trying again with GPU acceleration off")
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||||
|
_ = os.WriteFile(p, []byte(time.Now().Format(time.RFC3339)), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearLaunchMarker is called once the window is up: the attempt finished.
|
||||||
|
func clearLaunchMarker() { _ = os.Remove(stuckMarkerPath()) }
|
||||||
|
|
||||||
|
// fixedWebView2Path finds a FIXED-VERSION WebView2 runtime shipped beside
|
||||||
|
// OpsLog, or "" when there is none.
|
||||||
|
//
|
||||||
|
// Microsoft publishes the runtime in two forms. Evergreen installs itself into
|
||||||
|
// Windows and updates itself — the normal case, and the one this looks for
|
||||||
|
// first. FIXED VERSION is a folder of files an application carries with it and
|
||||||
|
// points at, needing no installation and no administrator: it exists precisely
|
||||||
|
// for machines where Evergreen cannot be installed, which is not a rare
|
||||||
|
// situation on a managed, offline or otherwise locked-down Windows.
|
||||||
|
//
|
||||||
|
// So: drop the extracted folder next to OpsLog.exe and it is used. Nothing to
|
||||||
|
// configure — a setting for this would be a question asked of the one operator
|
||||||
|
// least able to answer it, on a machine where nothing starts.
|
||||||
|
func fixedWebView2Path() string {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
base := filepath.Dir(exe)
|
||||||
|
for _, name := range []string{"WebView2", "WebView2Runtime", "webview2"} {
|
||||||
|
dir := filepath.Join(base, name)
|
||||||
|
if p := findWebView2Binary(dir); p != "" {
|
||||||
|
bootLog("using the fixed-version WebView2 runtime in %s", p)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// findWebView2Binary returns dir (or its single versioned subfolder) when it
|
||||||
|
// holds msedgewebview2.exe — the fixed-version download unpacks with the
|
||||||
|
// version as a folder level, and asking someone to flatten it by hand is one
|
||||||
|
// more step to get wrong.
|
||||||
|
func findWebView2Binary(dir string) string {
|
||||||
|
if fileExists(filepath.Join(dir, "msedgewebview2.exe")) {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sub := filepath.Join(dir, e.Name())
|
||||||
|
if fileExists(filepath.Join(sub, "msedgewebview2.exe")) {
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// edgeBlockedMessage is shown when an Edge blocker is found in the way.
|
||||||
|
//
|
||||||
|
// It names the tool rather than the symptom: OpsLog draws its whole interface
|
||||||
|
// with the WebView2 engine, which is Edge's, so a machine where Edge is blocked
|
||||||
|
// cannot run it — and no amount of reinstalling OpsLog will change that.
|
||||||
|
const edgeBlockedMessage = "OpsLog cannot start because Edge is blocked on this PC.\n\n" +
|
||||||
|
"A blocker tool (or a policy) is stopping msedgewebview2.exe from running. " +
|
||||||
|
"OpsLog draws its whole interface with that engine, so it cannot open its window.\n\n" +
|
||||||
|
"Unblock Edge — in the tool that blocked it — and start OpsLog again. " +
|
||||||
|
"The details are in the OpsLog folder under LOCALAPPDATA (startup.log)."
|
||||||
-18902
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,12 @@ func TestBulkEditFieldsAreWritable(t *testing.T) {
|
|||||||
id := m[1]
|
id := m[1]
|
||||||
// Handled before the column map: freq takes a numeric path (freq_hz +
|
// Handled before the column map: freq takes a numeric path (freq_hz +
|
||||||
// band together) and the extras live in extras_json.
|
// band together) and the extras live in extras_json.
|
||||||
|
// The integer My-station fields take their own numeric path
|
||||||
|
// (BulkSetIntField + bulkEditableIntCols in the repo), added after this
|
||||||
|
// guard was written — which is exactly the drift it exists to catch.
|
||||||
|
if id == "my_dxcc" || id == "my_cq_zone" || id == "my_itu_zone" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A bulk-editable field passes through THREE tables: the field list in the UI,
|
||||||
|
// bulkFieldColumns here, and bulkEditableCols in internal/qso. Mode and RST were
|
||||||
|
// added to the first and the third and not the second, so the dialog offered
|
||||||
|
// them and the save failed with "unknown field" — reported from the field.
|
||||||
|
//
|
||||||
|
// Nothing warns about that: each table is valid on its own. This is the check.
|
||||||
|
func TestEveryMappedBulkFieldIsWhitelisted(t *testing.T) {
|
||||||
|
for id, col := range bulkFieldColumns {
|
||||||
|
if !qso.BulkEditable(col) {
|
||||||
|
t.Errorf("bulk field %q maps to column %q, which internal/qso refuses to write", id, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the reverse: a column the qso layer allows but nothing maps to is dead
|
||||||
|
// weight — it looks supported from the inside and cannot be reached from outside.
|
||||||
|
func TestNoUnreachableBulkColumn(t *testing.T) {
|
||||||
|
mapped := map[string]bool{}
|
||||||
|
for _, col := range bulkFieldColumns {
|
||||||
|
mapped[col] = true
|
||||||
|
}
|
||||||
|
for _, col := range qso.BulkEditableColumns() {
|
||||||
|
if !mapped[col] {
|
||||||
|
t.Errorf("column %q is bulk-writable but no field maps to it — unreachable", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// How a Kenwood-dialect radio is reached.
|
||||||
|
//
|
||||||
|
// Three transports, and they are genuinely different things rather than three
|
||||||
|
// spellings of one: a COM port, the same CAT bytes carried over TCP by a serial
|
||||||
|
// bridge, and the radio's own network protocol — which is a session with its own
|
||||||
|
// framing and authentication, and is not implemented here.
|
||||||
|
//
|
||||||
|
// Kept as an explicit setting rather than inferred from which field an operator
|
||||||
|
// happened to fill in. The old code preferred a network address whenever one was
|
||||||
|
// present, which is invisible from the settings page: someone who typed a host
|
||||||
|
// months ago, then set a COM port, had a radio that never answered and nothing
|
||||||
|
// on screen to explain it.
|
||||||
|
|
||||||
|
const (
|
||||||
|
kenwoodLinkUSB = "usb" // a COM port
|
||||||
|
kenwoodLinkBridge = "bridge" // RS-232 to Ethernet: the same CAT bytes over TCP
|
||||||
|
kenwoodLinkNative = "native" // the radio's own network protocol — not supported
|
||||||
|
)
|
||||||
|
|
||||||
|
// kenwoodLinkOr resolves the stored choice, falling back to what an older
|
||||||
|
// install can be read as: a configured host meant the bridge, because that is
|
||||||
|
// what the previous code used it for.
|
||||||
|
func kenwoodLinkOr(link, host string) string {
|
||||||
|
switch link {
|
||||||
|
case kenwoodLinkUSB, kenwoodLinkBridge, kenwoodLinkNative:
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
if host != "" {
|
||||||
|
return kenwoodLinkBridge
|
||||||
|
}
|
||||||
|
return kenwoodLinkUSB
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The transport is a stored choice now, but installs exist that predate it and
|
||||||
|
// hold only a host. Reading those as "bridge" is what keeps a working station
|
||||||
|
// working across the upgrade — the old code used a host for exactly that.
|
||||||
|
func TestTheLinkIsReadFromAnOlderInstall(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
link, host, want string
|
||||||
|
}{
|
||||||
|
{"", "", kenwoodLinkUSB}, // nothing configured
|
||||||
|
{"", "192.168.1.50:4999", kenwoodLinkBridge}, // upgraded: host only
|
||||||
|
{kenwoodLinkUSB, "192.168.1.50:4999", kenwoodLinkUSB}, // chose USB, host left behind
|
||||||
|
{kenwoodLinkBridge, "", kenwoodLinkBridge},
|
||||||
|
{kenwoodLinkNative, "", kenwoodLinkNative},
|
||||||
|
{"nonsense", "", kenwoodLinkUSB}, // a value nobody wrote: fall back, don't guess
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := kenwoodLinkOr(c.link, c.host); got != c.want {
|
||||||
|
t.Errorf("link=%q host=%q → %q, want %q", c.link, c.host, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one that matters after the fact: an operator who picks USB while an old
|
||||||
|
// host is still stored must get the COM port. The previous code preferred the
|
||||||
|
// host whenever it was non-empty, which is invisible from the settings page.
|
||||||
|
func TestChoosingUSBBeatsALeftoverHost(t *testing.T) {
|
||||||
|
if got := kenwoodLinkOr(kenwoodLinkUSB, "10.0.0.9:4999"); got != kenwoodLinkUSB {
|
||||||
|
t.Fatalf("a leftover host overrode the operator's choice: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The whole point of the signature is that pressing Save in Preferences must
|
||||||
|
// not drop the rig — and with it every WSJT-X/JTDX client of the shared CAT
|
||||||
|
// server, mid-QSO, with an error box. Only a change to the LINK may reconnect.
|
||||||
|
func TestCatLinkSigIgnoresNonLinkSettings(t *testing.T) {
|
||||||
|
base := CATSettings{
|
||||||
|
Enabled: true, Backend: "flex", FlexHost: "192.168.1.20", FlexPort: 4992,
|
||||||
|
PollMs: 250, DigitalDefault: "FT8",
|
||||||
|
}
|
||||||
|
same := base
|
||||||
|
// Everything here is applied to the RUNNING manager; none of it justifies a
|
||||||
|
// reconnection, and a spot option in particular must not wipe the panadapter
|
||||||
|
// (a Flex reconnect clears every spot on it).
|
||||||
|
same.PollMs = 500
|
||||||
|
same.DelayMs = 20
|
||||||
|
same.OffsetOn = true
|
||||||
|
same.OffsetHz = 116_000_000
|
||||||
|
same.FlexDecodeSpots = !base.FlexDecodeSpots
|
||||||
|
same.FlexDecodeSecs = 300
|
||||||
|
same.FlexDVKDax = !base.FlexDVKDax
|
||||||
|
same.PTTHotkeyEnabled = true
|
||||||
|
same.PTTHotkey = "Pause"
|
||||||
|
if catLinkSig(base) != catLinkSig(same) {
|
||||||
|
t.Errorf("non-link settings changed the signature:\n%s\n%s", catLinkSig(base), catLinkSig(same))
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the ones that DO describe the connection must still force a restart.
|
||||||
|
for name, mutate := range map[string]func(*CATSettings){
|
||||||
|
"backend": func(c *CATSettings) { c.Backend = "kenwood" },
|
||||||
|
"flex host": func(c *CATSettings) { c.FlexHost = "192.168.1.21" },
|
||||||
|
"flex port": func(c *CATSettings) { c.FlexPort = 4993 },
|
||||||
|
"serial port": func(c *CATSettings) { c.Backend = "yaesu"; c.YaesuPort = "COM7" },
|
||||||
|
"baud": func(c *CATSettings) { c.Backend = "yaesu"; c.YaesuBaud = 19200 },
|
||||||
|
"civ address": func(c *CATSettings) { c.Backend = "icom"; c.IcomAddr = 152 },
|
||||||
|
"disabled": func(c *CATSettings) { c.Enabled = false },
|
||||||
|
} {
|
||||||
|
c := base
|
||||||
|
mutate(&c)
|
||||||
|
if catLinkSig(c) == catLinkSig(base) {
|
||||||
|
t.Errorf("%s did not change the signature — the rig would never reconnect", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The share server is the thing WSJT-X actually holds a socket to: restarting
|
||||||
|
// it is what produced the error box, so it has a signature of its own.
|
||||||
|
func TestCatShareSig(t *testing.T) {
|
||||||
|
base := CATSettings{Enabled: true, ShareEnabled: true, ShareProto: "rigctl", SharePort: 4532}
|
||||||
|
other := base
|
||||||
|
other.FlexHost = "10.0.0.5" // a rig-link change must not restart the server
|
||||||
|
if catShareSig(base) != catShareSig(other) {
|
||||||
|
t.Errorf("a rig-link change restarted the share server")
|
||||||
|
}
|
||||||
|
moved := base
|
||||||
|
moved.SharePort = 4533
|
||||||
|
if catShareSig(base) == catShareSig(moved) {
|
||||||
|
t.Errorf("a new port must restart the server")
|
||||||
|
}
|
||||||
|
off := base
|
||||||
|
off.ShareEnabled = false
|
||||||
|
if catShareSig(off) != "off" || catShareSig(off) == catShareSig(base) {
|
||||||
|
t.Errorf("switching sharing off must be a change")
|
||||||
|
}
|
||||||
|
// CAT off means the server has nothing to serve, whatever the share fields say.
|
||||||
|
catOff := base
|
||||||
|
catOff.Enabled = false
|
||||||
|
if catShareSig(catOff) != "off" {
|
||||||
|
t.Errorf("share signature with CAT off = %q, want \"off\"", catShareSig(catOff))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What a rigctl client is told when it asks "what frequency is the radio on".
|
||||||
|
//
|
||||||
|
// Hamlib's "f" means the VFO IN USE — where the operator is listening. RigState
|
||||||
|
// follows ADIF, where FreqHz is where we TRANSMIT, and the two are the same
|
||||||
|
// number until split is engaged. They were handed over unchanged, so with split
|
||||||
|
// on every client asking for the dial was given VFO B.
|
||||||
|
//
|
||||||
|
// Reported from a station running an IC-7850 over USB: turning VFO B moved
|
||||||
|
// VFO A. OpsLog was writing nothing to that radio — a client read the dial, was
|
||||||
|
// handed the transmit VFO, and wrote it back as the dial.
|
||||||
|
func TestShareRXFreqAnswersTheListeningVFO(t *testing.T) {
|
||||||
|
const rx, tx = 14195000, 14200500
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
st cat.RigState
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simplex — the one frequency there is",
|
||||||
|
st: cat.RigState{FreqHz: rx},
|
||||||
|
want: rx,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "split — the RX VFO, not the TX one",
|
||||||
|
st: cat.RigState{Split: true, FreqHz: tx, RxFreqHz: rx},
|
||||||
|
want: rx,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A backend that reports split without ever filling RxFreqHz would
|
||||||
|
// otherwise be answered with 0, and a client told the radio is on
|
||||||
|
// 0 Hz does something worse than nothing with it.
|
||||||
|
name: "split claimed but no RX frequency known",
|
||||||
|
st: cat.RigState{Split: true, FreqHz: tx},
|
||||||
|
want: tx,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := shareRXFreq(c.st); got != c.want {
|
||||||
|
t.Errorf("shareRXFreq(%+v) = %d, want %d", c.st, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the pair, stated together: the two questions must not return the same
|
||||||
|
// answer while split is on, or the distinction has been lost somewhere.
|
||||||
|
func TestShareSplitReportsTheOtherVFO(t *testing.T) {
|
||||||
|
st := cat.RigState{Split: true, FreqHz: 14200500, RxFreqHz: 14195000}
|
||||||
|
rxAnswer := shareRXFreq(st)
|
||||||
|
txAnswer := st.FreqHz // what Split() hands back for "i" / get_split_freq
|
||||||
|
if rxAnswer == txAnswer {
|
||||||
|
t.Fatalf("get_freq and get_split_freq both answered %d — a client cannot tell the VFOs apart", rxAnswer)
|
||||||
|
}
|
||||||
|
if rxAnswer != st.RxFreqHz || txAnswer != st.FreqHz {
|
||||||
|
t.Errorf("got rx=%d tx=%d, want rx=%d tx=%d", rxAnswer, txAnswer, st.RxFreqHz, st.FreqHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1482
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/clublog"
|
||||||
|
"hamlog/internal/dxcc"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reported from a real import: every ZK2 contact came back as New Zealand and a
|
||||||
|
// confirmed entity — Niue — disappeared from the operator's DXCC.
|
||||||
|
//
|
||||||
|
// cty.dat is not wrong, it is CURRENT: Niue moved to E6, so ZK2 reverted to New
|
||||||
|
// Zealand there. ClubLog still knows ZK2 was Niue, and knowing that is the whole
|
||||||
|
// reason for enabling its country file.
|
||||||
|
func TestClublogPrefixRescuesRetiredPrefixes(t *testing.T) {
|
||||||
|
dir := filepath.Join("build", "bin", "data")
|
||||||
|
dm := dxcc.NewManager(dir)
|
||||||
|
if err := dm.LoadFromDisk(); err != nil {
|
||||||
|
t.Skipf("cty.dat not available here: %v", err)
|
||||||
|
}
|
||||||
|
cm := clublog.NewManager("", dir)
|
||||||
|
if err := cm.EnsureLoaded(); err != nil {
|
||||||
|
t.Skipf("ClubLog country file not available here: %v", err)
|
||||||
|
}
|
||||||
|
a := &App{ctx: context.Background(), dxcc: dm, clublog: cm}
|
||||||
|
|
||||||
|
when := time.Date(2005, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
cases := []struct {
|
||||||
|
call string
|
||||||
|
want int // ADIF entity
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{"ZK2KK", 188, "Niue — the reported case"},
|
||||||
|
{"ZK1XYZ", 234, "South Cook Islands, lost the same way"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
q := qso.QSO{Callsign: c.call, QSODate: when}
|
||||||
|
if !a.applyClublogException(&q, true) {
|
||||||
|
t.Errorf("%s: ClubLog changed nothing (%s)", c.call, c.why)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.DXCC == nil || *q.DXCC != c.want {
|
||||||
|
got := 0
|
||||||
|
if q.DXCC != nil {
|
||||||
|
got = *q.DXCC
|
||||||
|
}
|
||||||
|
t.Errorf("%s resolved to %d (%s), want %d — %s", c.call, got, q.Country, c.want, c.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A callsign ClubLog's prefix table does not know must be left to cty.dat
|
||||||
|
// rather than blanked: silence is not an answer.
|
||||||
|
q := qso.QSO{Callsign: "E6AG", QSODate: when}
|
||||||
|
if a.applyClublogException(&q, true) && q.DXCC != nil && *q.DXCC != 188 {
|
||||||
|
t.Errorf("E6AG was moved off Niue, to %d", *q.DXCC)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"hamlog/internal/award"
|
"hamlog/internal/award"
|
||||||
)
|
)
|
||||||
@@ -58,6 +59,15 @@ func main() {
|
|||||||
sc.Scan() // header
|
sc.Scan() // header
|
||||||
for sc.Scan() {
|
for sc.Scan() {
|
||||||
line := sc.Text()
|
line := sc.Text()
|
||||||
|
// The FIPS CSV is not reliably UTF-8, and the one county whose name is
|
||||||
|
// not ASCII (Doña Ana, NM) came through mangled once already — it landed
|
||||||
|
// in the reference as "NM/DO̱AANA", a code no log could ever match,
|
||||||
|
// silently costing that county for every operator. Refuse the row rather
|
||||||
|
// than emit a broken one.
|
||||||
|
if !utf8.ValidString(line) {
|
||||||
|
fmt.Fprintf(os.Stderr, "cntygen: skipping non-UTF-8 line: %q\n", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
parts := strings.SplitN(line, ",", 3)
|
parts := strings.SplitN(line, ",", 3)
|
||||||
if len(parts) < 3 {
|
if len(parts) < 3 {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// One-shot generator: emits internal/uls/ctcounty_gen.go, the Connecticut
|
||||||
|
// ZIP → legal county table. Not part of the build.
|
||||||
|
//
|
||||||
|
// Why it has to exist
|
||||||
|
// -------------------
|
||||||
|
// internal/uls resolves a US callsign to a county through GeoNames' ZIP table.
|
||||||
|
// The Census replaced Connecticut's eight counties with nine PLANNING REGIONS
|
||||||
|
// as county-equivalents in 2022, and GeoNames followed: 418 of Connecticut's
|
||||||
|
// 429 ZIPs now report "Capitol Region", "Lower Connecticut River Valley" and
|
||||||
|
// so on. CQ's USA-CA award did not follow, callbooks did not follow, and no
|
||||||
|
// operator's log did either — so every Connecticut station resolved to a name
|
||||||
|
// nothing could match, showed as a new county for ever, and counted for no
|
||||||
|
// award. Roughly 15 000 US callsigns.
|
||||||
|
//
|
||||||
|
// A planning region is NOT a renamed county — it is built from towns drawn
|
||||||
|
// from several different counties — so there is no name-to-name mapping to be
|
||||||
|
// had. The ZIP is the only handle, hence this table.
|
||||||
|
//
|
||||||
|
// Sources (both public, both free)
|
||||||
|
// --------------------------------
|
||||||
|
// geonames US.txt from https://download.geonames.org/export/zip/US.zip
|
||||||
|
// — the ZIP list itself, and each ZIP's centroid.
|
||||||
|
// crosswalk https://www2.census.gov/geo/docs/maps-data/data/rel2020/
|
||||||
|
// zcta520/tab20_zcta520_county20_natl.txt
|
||||||
|
// — the 2020 ZCTA↔county relationship file, which predates the
|
||||||
|
// change and therefore still carries the eight real counties.
|
||||||
|
//
|
||||||
|
// The crosswalk covers 278 of the 418 affected ZIPs. The rest are PO-box-only
|
||||||
|
// ZIPs, which have no ZCTA at all; each is given the county of the nearest
|
||||||
|
// resolved ZIP centroid. That is sound here because a PO-box ZIP sits inside
|
||||||
|
// the town it serves, and Connecticut's counties are tens of kilometres across
|
||||||
|
// — the fallback can only err on a ZIP that straddles a county line, which the
|
||||||
|
// ZIP-to-county approach is already documented as accepting (~98%).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./cmd/ctzipgen US.txt tab20_zcta520_county20_natl.txt > internal/uls/ctcounty_gen.go
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type zipPt struct {
|
||||||
|
zip string
|
||||||
|
lat, lon float64
|
||||||
|
county string // from the crosswalk, "" if the ZIP has no ZCTA
|
||||||
|
geoName string // what GeoNames says today
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: ctzipgen US.txt tab20_zcta520_county20_natl.txt")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Every Connecticut ZIP, with its centroid, from GeoNames.
|
||||||
|
var pts []*zipPt
|
||||||
|
byZip := map[string]*zipPt{}
|
||||||
|
f, err := os.Open(os.Args[1])
|
||||||
|
must(err)
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 256*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
c := strings.Split(sc.Text(), "\t")
|
||||||
|
if len(c) < 11 || strings.ToUpper(strings.TrimSpace(c[4])) != "CT" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
z := strings.TrimSpace(c[1])
|
||||||
|
if z == "" || byZip[z] != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := &zipPt{zip: z, lat: atof(c[9]), lon: atof(c[10]), geoName: strings.TrimSpace(c[5])}
|
||||||
|
byZip[z] = p
|
||||||
|
pts = append(pts, p)
|
||||||
|
}
|
||||||
|
must(sc.Err())
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
// 2. The 2020 county for each ZCTA. A ZCTA can straddle a county line, so
|
||||||
|
// keep the county holding the largest share of its land area.
|
||||||
|
best := map[string]float64{}
|
||||||
|
f2, err := os.Open(os.Args[2])
|
||||||
|
must(err)
|
||||||
|
sc2 := bufio.NewScanner(f2)
|
||||||
|
sc2.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||||
|
sc2.Scan() // header
|
||||||
|
for sc2.Scan() {
|
||||||
|
c := strings.Split(sc2.Text(), "|")
|
||||||
|
if len(c) < 18 || !strings.HasPrefix(c[9], "09") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := byZip[strings.TrimSpace(c[1])]
|
||||||
|
if p == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
area := atof(c[16])
|
||||||
|
if p.county != "" && area <= best[p.zip] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
best[p.zip] = area
|
||||||
|
p.county = strings.TrimSuffix(strings.TrimSpace(c[10]), " County")
|
||||||
|
}
|
||||||
|
must(sc2.Err())
|
||||||
|
f2.Close()
|
||||||
|
|
||||||
|
// 3. Fill the PO-box-only ZIPs from the nearest ZIP the crosswalk resolved.
|
||||||
|
var anchors []*zipPt
|
||||||
|
for _, p := range pts {
|
||||||
|
if p.county != "" {
|
||||||
|
anchors = append(anchors, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(anchors) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "ctzipgen: crosswalk resolved nothing — wrong file?")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
filled := 0
|
||||||
|
for _, p := range pts {
|
||||||
|
if p.county != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nearest, bestD := "", math.MaxFloat64
|
||||||
|
for _, a := range anchors {
|
||||||
|
if d := haversine(p.lat, p.lon, a.lat, a.lon); d < bestD {
|
||||||
|
bestD, nearest = d, a.county
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.county = nearest
|
||||||
|
filled++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Self-check: the ZIPs GeoNames still labels with a real county must
|
||||||
|
// agree with what we derived, or the derivation is wrong.
|
||||||
|
real := map[string]bool{
|
||||||
|
"Fairfield": true, "Hartford": true, "Litchfield": true, "Middlesex": true,
|
||||||
|
"New Haven": true, "New London": true, "Tolland": true, "Windham": true,
|
||||||
|
}
|
||||||
|
checked, bad := 0, 0
|
||||||
|
for _, p := range pts {
|
||||||
|
if !real[p.geoName] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
checked++
|
||||||
|
if p.geoName != p.county {
|
||||||
|
bad++
|
||||||
|
fmt.Fprintf(os.Stderr, "MISMATCH %s: geonames %q, derived %q\n", p.zip, p.geoName, p.county)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "ctzipgen: %d zips, %d from crosswalk, %d by nearest; cross-check %d/%d agree\n",
|
||||||
|
len(pts), len(pts)-filled, filled, checked-bad, checked)
|
||||||
|
if bad > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(pts, func(i, j int) bool { return pts[i].zip < pts[j].zip })
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("// Code generated by cmd/ctzipgen. DO NOT EDIT.\n\n")
|
||||||
|
b.WriteString("package uls\n\n")
|
||||||
|
b.WriteString("// ctCounty maps a Connecticut ZIP to its legal county.\n")
|
||||||
|
b.WriteString("//\n")
|
||||||
|
b.WriteString("// GeoNames reports Connecticut's 2022 planning regions instead, which no\n")
|
||||||
|
b.WriteString("// award, callbook or log uses. See cmd/ctzipgen for how this was built and\n")
|
||||||
|
b.WriteString("// why a name-to-name mapping cannot work.\n")
|
||||||
|
fmt.Fprintf(&b, "var ctCounty = map[string]string{\n")
|
||||||
|
for _, p := range pts {
|
||||||
|
fmt.Fprintf(&b, "\t%q: %q,\n", p.zip, p.county)
|
||||||
|
}
|
||||||
|
b.WriteString("}\n")
|
||||||
|
fmt.Print(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func atof(s string) float64 {
|
||||||
|
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// haversine returns the great-circle distance in km. Connecticut is small
|
||||||
|
// enough that a flat approximation would do, but this costs nothing and cannot
|
||||||
|
// be wrong near the state's edges.
|
||||||
|
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
|
||||||
|
const r = 6371.0
|
||||||
|
dLat := (lat2 - lat1) * math.Pi / 180
|
||||||
|
dLon := (lon2 - lon1) * math.Pi / 180
|
||||||
|
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||||
|
math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||||
|
return 2 * r * math.Asin(math.Sqrt(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(err error) {
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "ctzipgen:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -109,9 +109,9 @@ func probeWB(conn *sql.DB, call string, dxcc int) {
|
|||||||
SELECT band, mode,
|
SELECT band, mode,
|
||||||
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
|
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
|
||||||
MAX(CASE WHEN callsign = ?
|
MAX(CASE WHEN callsign = ?
|
||||||
AND (lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y')
|
AND (lotw_rcvd IN ('Y','V') OR qsl_rcvd IN ('Y','V') OR eqsl_rcvd IN ('Y','V'))
|
||||||
THEN 1 ELSE 0 END),
|
THEN 1 ELSE 0 END),
|
||||||
MAX(CASE WHEN lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'
|
MAX(CASE WHEN lotw_rcvd IN ('Y','V') OR qsl_rcvd IN ('Y','V') OR eqsl_rcvd IN ('Y','V')
|
||||||
THEN 1 ELSE 0 END)
|
THEN 1 ELSE 0 END)
|
||||||
FROM qso WHERE dxcc = ?
|
FROM qso WHERE dxcc = ?
|
||||||
GROUP BY band, mode
|
GROUP BY band, mode
|
||||||
|
|||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Compacting a database.
|
||||||
|
//
|
||||||
|
// SQLite never shrinks a file on its own: deleting rows — or dropping a table,
|
||||||
|
// which is what the schema-role cleanup does — frees pages INSIDE the file and
|
||||||
|
// leaves the file the size it always was. A logbook that held 200 000 imported
|
||||||
|
// QSOs for a day is still a 200 MB file the day after they are gone, and the
|
||||||
|
// only thing that reclaims the space is a VACUUM, which rewrites the database
|
||||||
|
// from scratch.
|
||||||
|
//
|
||||||
|
// Offered as a button rather than done automatically: a VACUUM rewrites the
|
||||||
|
// whole file, needs room for a second copy of it while it runs, and takes real
|
||||||
|
// time on a large logbook. That is a decision for the operator, at a moment they
|
||||||
|
// choose — not something to spring on them during startup.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompactResult is what one compaction did.
|
||||||
|
type CompactResult struct {
|
||||||
|
// Path of the file compacted, empty for MySQL.
|
||||||
|
Path string `json:"path"`
|
||||||
|
// Backend is "sqlite" or "mysql" — a shared server is optimised, not vacuumed.
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
// Before and After are file sizes in bytes; both 0 when there is no file.
|
||||||
|
Before int64 `json:"before"`
|
||||||
|
After int64 `json:"after"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompactDatabase reclaims the unused space in one of the two databases.
|
||||||
|
//
|
||||||
|
// target is "settings" or "logbook". They are separate on purpose: they are
|
||||||
|
// different files, of very different sizes, and an operator compacting a 400 MB
|
||||||
|
// logbook has no reason to wait on a 2 MB settings file as well.
|
||||||
|
func (a *App) CompactDatabase(target string) (CompactResult, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(target)) {
|
||||||
|
case "settings":
|
||||||
|
if a.db == nil {
|
||||||
|
return CompactResult{}, fmt.Errorf("the settings database is not open")
|
||||||
|
}
|
||||||
|
return a.vacuumSQLite(a.db, a.dbPath)
|
||||||
|
case "logbook":
|
||||||
|
if a.logDb == nil {
|
||||||
|
return CompactResult{}, fmt.Errorf("the logbook is not open")
|
||||||
|
}
|
||||||
|
if a.dbBackend == "mysql" {
|
||||||
|
// A shared server's storage is the admin's business, and OPTIMIZE TABLE
|
||||||
|
// locks the table for the length of a rebuild — every other operator
|
||||||
|
// waits. Still offered, because a logbook that has had a large import
|
||||||
|
// deleted benefits from it just as much; it simply reports no sizes,
|
||||||
|
// which the server alone knows.
|
||||||
|
if _, err := a.logDb.ExecContext(a.ctx, "OPTIMIZE TABLE qso"); err != nil {
|
||||||
|
return CompactResult{}, fmt.Errorf("optimize qso: %w", err)
|
||||||
|
}
|
||||||
|
applog.Printf("compact: OPTIMIZE TABLE qso on the shared MySQL logbook")
|
||||||
|
return CompactResult{Backend: "mysql"}, nil
|
||||||
|
}
|
||||||
|
// No separate file: the settings database is serving as the logbook, and
|
||||||
|
// compacting it is the same operation.
|
||||||
|
if a.logDbPath == "" {
|
||||||
|
return a.vacuumSQLite(a.db, a.dbPath)
|
||||||
|
}
|
||||||
|
return a.vacuumSQLite(a.logDb, a.logDbPath)
|
||||||
|
}
|
||||||
|
return CompactResult{}, fmt.Errorf("unknown database %q", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vacuumSQLite checkpoints the write-ahead log, then rewrites the file.
|
||||||
|
func (a *App) vacuumSQLite(conn *sql.DB, path string) (CompactResult, error) {
|
||||||
|
res := CompactResult{Path: path, Backend: "sqlite", Before: fileSizeOf(path)}
|
||||||
|
// Fold the WAL back into the main file first. Without it the pages freed by a
|
||||||
|
// recent DELETE can still be sitting in the -wal, and the vacuum reports a
|
||||||
|
// saving the file on disk does not show.
|
||||||
|
if _, err := conn.ExecContext(a.ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||||
|
applog.Printf("compact: wal checkpoint on %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if _, err := conn.ExecContext(a.ctx, "VACUUM"); err != nil {
|
||||||
|
return res, fmt.Errorf("vacuum: %w", err)
|
||||||
|
}
|
||||||
|
res.After = fileSizeOf(path)
|
||||||
|
applog.Printf("compact: %s %d → %d bytes", path, res.Before, res.After)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileSizeOf returns a file's size, or 0 if it cannot be read.
|
||||||
|
func fileSizeOf(path string) int64 {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return fi.Size()
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCopyDirContentsCreatesDestination pins the bug that broke copying a QSL
|
||||||
|
// design between profiles.
|
||||||
|
//
|
||||||
|
// copyDirContents wrote straight into dst without creating it, so the first
|
||||||
|
// os.Create failed with ENOENT. The caller was written as "ignore
|
||||||
|
// os.IsNotExist", meaning "the source has no assets, that is fine" — and ENOENT
|
||||||
|
// from the failed write matched it exactly. Nothing was copied, no error was
|
||||||
|
// reported, and the operator saw "copied design is incomplete: hero photo file
|
||||||
|
// … not found" from the validation two lines later.
|
||||||
|
func TestCopyDirContentsCreatesDestination(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
src := filepath.Join(base, "src")
|
||||||
|
if err := os.MkdirAll(filepath.Join(src, "sub"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "img_25c06bda.jpg"), []byte("hero"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "sub", "back.png"), []byte("back"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dst does not exist — the case of every first copy.
|
||||||
|
dst := filepath.Join(base, "dst")
|
||||||
|
if err := copyDirContents(src, dst); err != nil {
|
||||||
|
t.Fatalf("copyDirContents: %v", err)
|
||||||
|
}
|
||||||
|
for name, want := range map[string]string{
|
||||||
|
"img_25c06bda.jpg": "hero",
|
||||||
|
"sub/back.png": "back",
|
||||||
|
} {
|
||||||
|
got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(name)))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("%s = %q, want %q", name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing SOURCE is still an error from copyDirContents itself — it is the
|
||||||
|
// caller that decides whether a design with no pictures is acceptable, and it
|
||||||
|
// can only decide that if this does not quietly succeed.
|
||||||
|
func TestCopyDirContentsMissingSource(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
if err := copyDirContents(filepath.Join(base, "nope"), filepath.Join(base, "dst")); err == nil {
|
||||||
|
t.Fatal("copying a missing source returned no error")
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("want a not-exist error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/gridcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The grid cache used to drop EVERYTHING at its ceiling. That was survivable
|
||||||
|
// while the only feed was this station's own decodes, which never reached it.
|
||||||
|
// Fed by anything larger it is a cliff: every locator in the cluster list
|
||||||
|
// disappears at once, periodically, and the operator sees the column empty
|
||||||
|
// itself for no reason.
|
||||||
|
//
|
||||||
|
// Rotation keeps the previous generation, so a full cache costs the older half
|
||||||
|
// and nothing more.
|
||||||
|
func TestDecodeGridRotationKeepsThePreviousGeneration(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
|
||||||
|
// Fill exactly one generation.
|
||||||
|
for i := 0; i < decodeGridsCap; i++ {
|
||||||
|
a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||||
|
t.Fatalf("first entry lost before any rotation: %q", got)
|
||||||
|
}
|
||||||
|
if a.decodeGridsOld != nil {
|
||||||
|
t.Fatal("rotated early — the cap is the ceiling of ONE generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// One more entry rotates.
|
||||||
|
a.rememberDecodeGrid("NEWCALL", "IO91", gridcache.SourceDecode)
|
||||||
|
if a.decodeGridsOld == nil {
|
||||||
|
t.Fatal("did not rotate at the cap")
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" {
|
||||||
|
t.Errorf("the entry that caused the rotation was lost: %q", got)
|
||||||
|
}
|
||||||
|
// The whole previous generation is still readable — this is the point.
|
||||||
|
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||||
|
t.Errorf("a locator from the previous generation was dropped: %q — that is the cliff again", got)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("CALL099999"); got != "JN36" {
|
||||||
|
t.Errorf("previous generation incomplete: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second rotation is what finally retires the oldest half.
|
||||||
|
for i := 0; i < decodeGridsCap; i++ {
|
||||||
|
a.rememberDecodeGrid(fmt.Sprintf("SECOND%06d", i), "KP20", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("CALL000000"); got != "" {
|
||||||
|
t.Errorf("the cache is unbounded: %q survived two rotations", got)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" {
|
||||||
|
t.Errorf("an entry one generation old was retired too early: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callsigns are normalised on the way in AND on the way out, or a spot for
|
||||||
|
// "f4bpo" would miss a grid learnt as "F4BPO".
|
||||||
|
func TestDecodeGridCaseAndBlanks(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.rememberDecodeGrid(" f4bpo ", "JN36", gridcache.SourceDecode)
|
||||||
|
if got := a.lookupDecodeGrid("F4BPO"); got != "JN36" {
|
||||||
|
t.Errorf("lookup of the upper-case form failed: %q", got)
|
||||||
|
}
|
||||||
|
a.rememberDecodeGrid("", "JN36", gridcache.SourceDecode)
|
||||||
|
a.rememberDecodeGrid("K1ABC", "", gridcache.SourceDecode)
|
||||||
|
if got := a.lookupDecodeGrid("K1ABC"); got != "" {
|
||||||
|
t.Errorf("stored an empty grid: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The write path runs on the decode goroutine while the cluster status builder
|
||||||
|
// reads. Rotation swaps the map headers, so an unguarded read is a data race
|
||||||
|
// rather than a stale value.
|
||||||
|
//
|
||||||
|
// This build is CGO-free, so -race is not available here; the test exercises the
|
||||||
|
// interleaving and would fault on a concurrent map access even without it.
|
||||||
|
func TestDecodeGridConcurrentAccess(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 20000; i++ {
|
||||||
|
a.rememberDecodeGrid(fmt.Sprintf("W%05d", i), "FN31", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 20000; i++ {
|
||||||
|
_ = a.lookupDecodeGrid(fmt.Sprintf("W%05d", i))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a CHANGE may reach the write batch.
|
||||||
|
//
|
||||||
|
// The feeds repeat themselves — the same station is reported by dozens of
|
||||||
|
// receivers a minute — so queueing every report would put the whole stream in
|
||||||
|
// the batch instead of the news in it, and turn a cache into a write amplifier.
|
||||||
|
func TestOnlyChangesAreQueued(t *testing.T) {
|
||||||
|
st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
a := &App{gridStore: st}
|
||||||
|
|
||||||
|
a.rememberDecodeGrid("F4BPO", "JN36", gridcache.SourceDecode)
|
||||||
|
if n := st.Pending(); n != 1 {
|
||||||
|
t.Fatalf("a new locator queued %d writes, want 1", n)
|
||||||
|
}
|
||||||
|
if err := st.Flush(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same report, a hundred times over, is not news.
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
a.rememberDecodeGrid("F4BPO", "JN36", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
if n := st.Pending(); n != 0 {
|
||||||
|
t.Errorf("unchanged reports queued %d writes — the batch would carry the whole feed", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A station that moved is.
|
||||||
|
a.rememberDecodeGrid("F4BPO", "KP30", gridcache.SourceDecode)
|
||||||
|
if n := st.Pending(); n != 1 {
|
||||||
|
t.Errorf("a changed locator queued %d writes, want 1", n)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("F4BPO"); got != "KP30" {
|
||||||
|
t.Errorf("the map kept the old locator: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotation must be OFF while a store is attached: it would discard callsigns the
|
||||||
|
// database still holds, and the lookup would then miss something we know.
|
||||||
|
func TestNoRotationWhilePersisting(t *testing.T) {
|
||||||
|
st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
a := &App{gridStore: st}
|
||||||
|
|
||||||
|
for i := 0; i < decodeGridsCap+10; i++ {
|
||||||
|
a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
if a.decodeGridsOld != nil {
|
||||||
|
t.Error("rotated while a store was attached — locators the database holds would go missing")
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||||
|
t.Errorf("the first entry was dropped: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Confirmations default of "R" means "I intend to upload this", not "it has
|
||||||
|
// gone" — reading it as sent would silently disable the auto-upload the default
|
||||||
|
// was set to arm. Only a real stamp blocks.
|
||||||
|
func TestExtrasSaysSent(t *testing.T) {
|
||||||
|
pending := []string{"", " ", "R", "r", "N", "Q", "I"}
|
||||||
|
for _, v := range pending {
|
||||||
|
if extrasSaysSent(v) {
|
||||||
|
t.Errorf("extrasSaysSent(%q) = true, want false (still to upload)", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sent := []string{"Y", "y", "20260831"} // "Y" today, a date in older builds
|
||||||
|
for _, v := range sent {
|
||||||
|
if !extrasSaysSent(v) {
|
||||||
|
t.Errorf("extrasSaysSent(%q) = false, want true (already uploaded)", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The HamQTH default lands on the extras key the uploader reads, and must leave
|
||||||
|
// the QSO eligible.
|
||||||
|
func TestHamQTHDefaultStaysUploadable(t *testing.T) {
|
||||||
|
q := &qso.QSO{Callsign: "F4BPO"}
|
||||||
|
applyQSLDefaultsTo(q, defaultQSLDefaults())
|
||||||
|
if got := q.Extras[hamqthSentKey]; got != "R" {
|
||||||
|
t.Fatalf("HamQTH sent extra = %q, want %q", got, "R")
|
||||||
|
}
|
||||||
|
if extrasSaysSent(q.Extras[hamqthSentKey]) {
|
||||||
|
t.Error("a freshly logged QSO reads as already uploaded to HamQTH")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// fatalBox is Windows-only; elsewhere the terminal carries the message.
|
||||||
|
func fatalBox(title, text string) { println(title + ": " + text) }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fatalBox shows a message box and returns.
|
||||||
|
//
|
||||||
|
// A GUI-subsystem program has no console: println goes nowhere, and a launch
|
||||||
|
// that ends before the window exists ends in complete silence. Every silent exit
|
||||||
|
// in main now says something here first — an operator who is told "another
|
||||||
|
// OpsLog is already running" can act on it; one who sees nothing files "it does
|
||||||
|
// not start", which is the report nobody can answer.
|
||||||
|
func fatalBox(title, text string) {
|
||||||
|
user32 := syscall.NewLazyDLL("user32.dll")
|
||||||
|
proc := user32.NewProc("MessageBoxW")
|
||||||
|
t, err1 := syscall.UTF16PtrFromString(text)
|
||||||
|
ti, err2 := syscall.UTF16PtrFromString(title)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const mbIconError = 0x00000010
|
||||||
|
proc.Call(0, uintptr(unsafe.Pointer(t)), uintptr(unsafe.Pointer(ti)), mbIconError)
|
||||||
|
}
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Chasing a split pile-up by the report the DX just sent.
|
||||||
|
//
|
||||||
|
// Working a DXpedition in split means guessing where it is listening. The DX
|
||||||
|
// answers one station, sends "5NN", and moves on; the useful information is
|
||||||
|
// therefore not the callsign it answered but the FREQUENCY that callsign was
|
||||||
|
// transmitting on, because the DX's receiver was there a second ago.
|
||||||
|
//
|
||||||
|
// A CW skimmer already knows this. SDC (Software Defined Connector) decodes the
|
||||||
|
// whole pile-up and marks each report on the panadapter as a spot — the marker
|
||||||
|
// TEXT is configured in SDC by the operator ("599" for a fresh report, "X" for
|
||||||
|
// an older one, by default). Those spots reach OpsLog already: it subscribes to
|
||||||
|
// the radio's spot feed, and every spot another program posts arrives on
|
||||||
|
// Flex.OnForeignSpot.
|
||||||
|
//
|
||||||
|
// So the whole feature is: recognise the marker, move the TRANSMIT slice there,
|
||||||
|
// leave the receive slice on the DX. Nothing here decodes anything.
|
||||||
|
//
|
||||||
|
// Three deliberate choices:
|
||||||
|
//
|
||||||
|
// - the marker text is a SETTING, not a constant. It is chosen in SDC, and
|
||||||
|
// any guess made here would be wrong for the operator who chose otherwise.
|
||||||
|
// - only the transmit slice moves, never the receive slice. Losing the DX is
|
||||||
|
// a worse outcome than a missed call.
|
||||||
|
// - the offset is signed and in Hz, because working "up a bit" from where the
|
||||||
|
// last station was answered is exactly how a pile-up is chased.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyFlexRSTChase = "flex.rst_chase"
|
||||||
|
|
||||||
|
// FlexRSTChase is the whole configuration.
|
||||||
|
type FlexRSTChase struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Markers are the skimmer's marker texts, comma-separated ("599,5NN").
|
||||||
|
// Matched against the spot's callsign field, case-insensitively and whole:
|
||||||
|
// a spot IS the marker or it is an ordinary callsign, and a substring rule
|
||||||
|
// would drag in any station whose call happens to contain the digits.
|
||||||
|
Markers string `json:"markers"`
|
||||||
|
// OffsetHz is added to the marker's frequency. Signed: chasing upward from
|
||||||
|
// the last station worked is the usual tactic, downward happens too.
|
||||||
|
OffsetHz int `json:"offset_hz"`
|
||||||
|
// SplitOnly refuses to act when the radio is not in split. On by default:
|
||||||
|
// out of split the transmit slice IS the receive slice, so "move the TX
|
||||||
|
// slice" would take the operator off the DX they are listening to.
|
||||||
|
SplitOnly bool `json:"split_only"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultFlexRSTChase = FlexRSTChase{Enabled: false, Markers: "599", OffsetHz: 0, SplitOnly: true}
|
||||||
|
|
||||||
|
// rstChaseMinGap throttles the moves. A skimmer marks every report it decodes,
|
||||||
|
// and a busy pile-up produces several a second; without a floor the transmit
|
||||||
|
// slice would twitch continuously and never be anywhere long enough to call.
|
||||||
|
const rstChaseMinGap = 700 * time.Millisecond
|
||||||
|
|
||||||
|
// rstChaseMinStep ignores a marker that lands where the slice already is.
|
||||||
|
// Re-sending the same frequency is not free: it is a command to the radio and a
|
||||||
|
// slice status back, several times a second, for no change at all.
|
||||||
|
const rstChaseMinStep = 20 // Hz
|
||||||
|
|
||||||
|
var (
|
||||||
|
rstChaseMu sync.Mutex
|
||||||
|
rstChaseLast time.Time
|
||||||
|
rstChaseFreq int64
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetFlexRSTChase returns the stored configuration (defaults when unset).
|
||||||
|
func (a *App) GetFlexRSTChase() FlexRSTChase {
|
||||||
|
s := defaultFlexRSTChase
|
||||||
|
if a.settings == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
// settingOr, NOT settings.Get with profileScope(): the store already applies
|
||||||
|
// the active profile's prefix, so scoping the key here wrote p3.flex.rst_chase
|
||||||
|
// and read p3.p3.flex.rst_chase — the switch could never be read back on, and
|
||||||
|
// the feature did nothing at all with no sign of why.
|
||||||
|
if v := a.settingOr(keyFlexRSTChase, ""); strings.TrimSpace(v) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(v), &s)
|
||||||
|
}
|
||||||
|
return normRSTChase(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveFlexRSTChase stores the configuration.
|
||||||
|
func (a *App) SaveFlexRSTChase(s FlexRSTChase) error {
|
||||||
|
b, err := json.Marshal(normRSTChase(s))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setSetting(keyFlexRSTChase, string(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFlexRSTChaseEnabled flips the switch alone.
|
||||||
|
//
|
||||||
|
// Its own binding because this belongs on a button in the panel, not in a
|
||||||
|
// settings page: it is turned on when a DXpedition appears and off when it is
|
||||||
|
// worked, which is a thing done mid-QSO with one hand.
|
||||||
|
func (a *App) SetFlexRSTChaseEnabled(on bool) error {
|
||||||
|
s := a.GetFlexRSTChase()
|
||||||
|
s.Enabled = on
|
||||||
|
return a.SaveFlexRSTChase(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normRSTChase(s FlexRSTChase) FlexRSTChase {
|
||||||
|
if strings.TrimSpace(s.Markers) == "" {
|
||||||
|
s.Markers = defaultFlexRSTChase.Markers
|
||||||
|
}
|
||||||
|
// A pile-up is a few kHz wide. Anything past that is a typo (Hz entered as
|
||||||
|
// if it were kHz), and honouring it would transmit far outside the segment.
|
||||||
|
if s.OffsetHz > 10000 {
|
||||||
|
s.OffsetHz = 10000
|
||||||
|
}
|
||||||
|
if s.OffsetHz < -10000 {
|
||||||
|
s.OffsetHz = -10000
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// rstChaseMarkerSet splits the configured markers into a comparison set.
|
||||||
|
func rstChaseMarkerSet(markers string) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, m := range strings.FieldsFunc(markers, func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) {
|
||||||
|
if m = strings.ToUpper(strings.TrimSpace(m)); m != "" {
|
||||||
|
out[m] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleForeignSpot is what a skimmer's spot arrives at.
|
||||||
|
func (a *App) handleForeignSpot(callsign string, freqHz int64) {
|
||||||
|
cfg := a.GetFlexRSTChase()
|
||||||
|
if !cfg.Enabled || a.cat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !rstChaseMarkerSet(cfg.Markers)[strings.ToUpper(strings.TrimSpace(callsign))] {
|
||||||
|
return // an ordinary spot: another station's callsign, not a report
|
||||||
|
}
|
||||||
|
// From here on every refusal is logged. A marker WAS recognised, so the
|
||||||
|
// operator is entitled to know why the slice stayed where it was — silence
|
||||||
|
// at this point is indistinguishable from a feature that does not work.
|
||||||
|
if cfg.SplitOnly && !a.cat.State().Split {
|
||||||
|
applog.Printf("rst chase: %s marked at %s, but the radio is not in split — ignored",
|
||||||
|
strings.ToUpper(callsign), hzText(freqHz))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := freqHz + int64(cfg.OffsetHz)
|
||||||
|
if target <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
rstChaseMu.Lock()
|
||||||
|
if now.Sub(rstChaseLast) < rstChaseMinGap {
|
||||||
|
rstChaseMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rstChaseFreq != 0 && absInt64(target-rstChaseFreq) < rstChaseMinStep {
|
||||||
|
rstChaseMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rstChaseLast, rstChaseFreq = now, target
|
||||||
|
rstChaseMu.Unlock()
|
||||||
|
|
||||||
|
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||||
|
return fc.SetTXSliceFrequency(target)
|
||||||
|
}); err != nil {
|
||||||
|
applog.Printf("rst chase: moving the TX slice to %s failed: %v", hzText(target), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("rst chase: %s marked at %s → TX slice to %s (offset %+d Hz)",
|
||||||
|
strings.ToUpper(callsign), hzText(freqHz), hzText(target), cfg.OffsetHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hzText renders a frequency the way an operator reads one on a dial.
|
||||||
|
func hzText(hz int64) string {
|
||||||
|
return strconv.FormatFloat(float64(hz)/1000, 'f', 3, 64) + " kHz"
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Panadapter auto-zoom on a spot click.
|
||||||
|
//
|
||||||
|
// A CW signal is 100 Hz wide, an FT8 slot 50 Hz in a 3 kHz sub-band, an SSB
|
||||||
|
// station 2.7 kHz. One panadapter width cannot serve all three: at 200 kHz the
|
||||||
|
// CW station you clicked is a hair, and at 25 kHz an FT8 window shows one
|
||||||
|
// eighth of the traffic. So the width follows the MODE of the spot, which is
|
||||||
|
// the only thing that determines how much spectrum is worth looking at.
|
||||||
|
//
|
||||||
|
// The command is SmartSDR's own: "display pan s <pan> bandwidth=<MHz>", with an
|
||||||
|
// optional re-centre. Same approach as DXHunter, which is where the defaults
|
||||||
|
// below come from.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyFlexZoom = "flex.spot_zoom"
|
||||||
|
|
||||||
|
// FlexZoomSettings is the per-mode-group visible width, in kHz.
|
||||||
|
//
|
||||||
|
// kHz and not MHz: an operator thinks "25 kHz of CW", and a form asking for
|
||||||
|
// 0.025 invites a zero too many — which on a panadapter is not an error message
|
||||||
|
// but a display that has silently gone somewhere else.
|
||||||
|
type FlexZoomSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CWkHz int `json:"cw_khz"`
|
||||||
|
SSBkHz int `json:"ssb_khz"`
|
||||||
|
DigikHz int `json:"digi_khz"`
|
||||||
|
// Centre re-centres the panadapter on EVERY spot, not just the ones that
|
||||||
|
// would otherwise fall outside the new window — the display always follows.
|
||||||
|
// Off by default: an operator who has arranged their window around a run
|
||||||
|
// frequency does not necessarily want it moved every time they look at
|
||||||
|
// someone else, and a spot already on screen stays where it is either way.
|
||||||
|
Centre bool `json:"centre"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultFlexZoom — the widths DXHunter settled on, in kHz.
|
||||||
|
var defaultFlexZoom = FlexZoomSettings{Enabled: false, CWkHz: 25, SSBkHz: 200, DigikHz: 50, Centre: false}
|
||||||
|
|
||||||
|
// flexZoomGroup maps a spot's mode to the three widths worth distinguishing.
|
||||||
|
//
|
||||||
|
// The mode arrives as whatever the cluster comment implied — "CW", "FT8",
|
||||||
|
// "USB", "RTTY" — so this takes the ADIF-ish name, not the Flex one. Anything
|
||||||
|
// unrecognised is digital, which is the safe end: a 50 kHz window is readable
|
||||||
|
// for every mode, where 25 kHz is not.
|
||||||
|
func flexZoomGroup(mode string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "CW", "CWR", "CWL", "CWU":
|
||||||
|
return "cw"
|
||||||
|
case "SSB", "USB", "LSB", "AM", "FM", "NFM", "DFM", "SAM", "PHONE", "DV":
|
||||||
|
return "ssb"
|
||||||
|
}
|
||||||
|
return "digi"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFlexZoom returns the settings, defaults included.
|
||||||
|
func (a *App) GetFlexZoom() FlexZoomSettings {
|
||||||
|
out := defaultFlexZoom
|
||||||
|
if raw := a.settingOr(keyFlexZoom, ""); raw != "" {
|
||||||
|
var s FlexZoomSettings
|
||||||
|
if err := json.Unmarshal([]byte(raw), &s); err == nil {
|
||||||
|
out = normFlexZoom(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// normFlexZoom clamps the widths to something a panadapter can actually show.
|
||||||
|
// 1 kHz is narrower than most filters; 14 MHz is a whole HF spectrum.
|
||||||
|
func normFlexZoom(s FlexZoomSettings) FlexZoomSettings {
|
||||||
|
clamp := func(v, def int) int {
|
||||||
|
if v < 1 || v > 14000 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
s.CWkHz = clamp(s.CWkHz, defaultFlexZoom.CWkHz)
|
||||||
|
s.SSBkHz = clamp(s.SSBkHz, defaultFlexZoom.SSBkHz)
|
||||||
|
s.DigikHz = clamp(s.DigikHz, defaultFlexZoom.DigikHz)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveFlexZoom persists the settings.
|
||||||
|
func (a *App) SaveFlexZoom(s FlexZoomSettings) error {
|
||||||
|
b, err := json.Marshal(normFlexZoom(s))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setSetting(keyFlexZoom, string(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexZoomForSpot resizes the panadapter for a spot that was just clicked.
|
||||||
|
//
|
||||||
|
// Called from the click handler, AFTER the rig has been tuned: the zoom is a
|
||||||
|
// view change and must never delay the frequency change an operator is waiting
|
||||||
|
// for. Does nothing at all unless the option is on and the backend is a Flex —
|
||||||
|
// no other radio has a panadapter to zoom.
|
||||||
|
func (a *App) FlexZoomForSpot(mode string, freqHz int64) {
|
||||||
|
if a.cat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
z := a.GetFlexZoom()
|
||||||
|
if !z.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
khz := z.DigikHz
|
||||||
|
switch flexZoomGroup(mode) {
|
||||||
|
case "cw":
|
||||||
|
khz = z.CWkHz
|
||||||
|
case "ssb":
|
||||||
|
khz = z.SSBkHz
|
||||||
|
}
|
||||||
|
_ = a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||||
|
return fc.ZoomPan(float64(khz)/1000, float64(freqHz)/1e6, z.Centre)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The width follows the mode, and an unrecognised mode must land on the widest
|
||||||
|
// of the three: a 50 kHz window is readable for everything, where 25 kHz shows
|
||||||
|
// one eighth of an FT8 sub-band.
|
||||||
|
func TestFlexZoomGroup(t *testing.T) {
|
||||||
|
for mode, want := range map[string]string{
|
||||||
|
"CW": "cw", "cw": "cw", "CWR": "cw",
|
||||||
|
"SSB": "ssb", "USB": "ssb", "LSB": "ssb", "FM": "ssb", "AM": "ssb",
|
||||||
|
"FT8": "digi", "FT4": "digi", "RTTY": "digi", "JS8": "digi", "PSK31": "digi",
|
||||||
|
"": "digi", "something new": "digi",
|
||||||
|
} {
|
||||||
|
if got := flexZoomGroup(mode); got != want {
|
||||||
|
t.Errorf("flexZoomGroup(%q) = %q, want %q", mode, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A width of zero would command "bandwidth=0.000000" and take the panadapter
|
||||||
|
// somewhere the operator cannot get back from by eye.
|
||||||
|
func TestNormFlexZoomClamps(t *testing.T) {
|
||||||
|
got := normFlexZoom(FlexZoomSettings{CWkHz: 0, SSBkHz: -5, DigikHz: 99999})
|
||||||
|
if got.CWkHz != defaultFlexZoom.CWkHz || got.SSBkHz != defaultFlexZoom.SSBkHz || got.DigikHz != defaultFlexZoom.DigikHz {
|
||||||
|
t.Errorf("out-of-range widths not replaced by the defaults: %+v", got)
|
||||||
|
}
|
||||||
|
ok := normFlexZoom(FlexZoomSettings{CWkHz: 10, SSBkHz: 150, DigikHz: 40})
|
||||||
|
if ok.CWkHz != 10 || ok.SSBkHz != 150 || ok.DigikHz != 40 {
|
||||||
|
t.Errorf("valid widths must be kept as they are: %+v", ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2687
-419
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Bell, Plus, Trash2, Volume2, Mail, Eye, X, Search } from 'lucide-react';
|
import { Bell, Plus, Trash2, Volume2, Mail, Eye, X, Search } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
@@ -102,23 +102,46 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
return alerts.Rule.createFrom({ ...d, [key]: next });
|
return alerts.Rule.createFrom({ ...d, [key]: next });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Saving used to be silent: the dialog stays open and the rule looks exactly
|
||||||
|
// as it did a second earlier, so there was nothing to tell an operator whether
|
||||||
|
// the click had registered. A line that says so, and clears itself.
|
||||||
|
const [savedMsg, setSavedMsg] = useState('');
|
||||||
|
const savedTimer = useRef(0);
|
||||||
|
function flashSaved(text: string) {
|
||||||
|
setSavedMsg(text);
|
||||||
|
window.clearTimeout(savedTimer.current);
|
||||||
|
savedTimer.current = window.setTimeout(() => setSavedMsg(''), 3000);
|
||||||
|
}
|
||||||
|
useEffect(() => () => window.clearTimeout(savedTimer.current), []);
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
||||||
try { const saved = await SaveAlertRule(draft); await refresh(); loadDraft(saved as Rule); setErr(''); }
|
try {
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
const saved = await SaveAlertRule(draft);
|
||||||
|
await refresh();
|
||||||
|
loadDraft(saved as Rule);
|
||||||
|
setErr('');
|
||||||
|
flashSaved(t('altm.saved', { name: draft.name.trim() }));
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function del() {
|
async function del() {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
if (!draft.id) { loadDraft(null); return; }
|
if (!draft.id) { loadDraft(null); return; }
|
||||||
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
||||||
try { await DeleteAlertRule(draft.id); loadDraft(null); await refresh(); }
|
try {
|
||||||
|
const name = draft.name;
|
||||||
|
await DeleteAlertRule(draft.id);
|
||||||
|
loadDraft(null);
|
||||||
|
await refresh();
|
||||||
|
flashSaved(t('altm.deleted', { name }));
|
||||||
|
}
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-4xl">
|
<DialogContent overlayBlur={false} className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle>
|
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle>
|
||||||
<DialogDescription>{t('altm.desc')}</DialogDescription>
|
<DialogDescription>{t('altm.desc')}</DialogDescription>
|
||||||
@@ -257,6 +280,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
{/* Editor actions */}
|
{/* Editor actions */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
|
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
|
||||||
{err && <span className="text-[11px] text-danger flex-1 truncate">{err}</span>}
|
{err && <span className="text-[11px] text-danger flex-1 truncate">{err}</span>}
|
||||||
|
{!err && savedMsg && <span className="text-[11px] text-success flex-1 truncate">{savedMsg}</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="text-danger" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button>
|
<Button variant="ghost" size="sm" className="text-danger" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button>
|
||||||
<Button size="sm" onClick={save}>{t('altm.saveRule')}</Button>
|
<Button size="sm" onClick={save}>{t('altm.saveRule')}</Button>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function powerLevelLabel(pl?: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; kpa?: any; pgxl?: any };
|
||||||
|
|
||||||
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
||||||
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
||||||
@@ -64,6 +64,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
|
|
||||||
const isSPE = !!amp.spe;
|
const isSPE = !!amp.spe;
|
||||||
const isACOM = !!amp.acom;
|
const isACOM = !!amp.acom;
|
||||||
|
const isKPA = !!amp.kpa;
|
||||||
|
|
||||||
if (isSPE) {
|
if (isSPE) {
|
||||||
const spe = amp.spe;
|
const spe = amp.spe;
|
||||||
@@ -127,7 +128,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
if (isACOM) {
|
if (isACOM) {
|
||||||
const acom = amp.acom;
|
const acom = amp.acom;
|
||||||
return (
|
return (
|
||||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `Acom${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<button type="button" disabled={!acom.connected}
|
<button type="button" disabled={!acom.connected}
|
||||||
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
|
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
|
||||||
@@ -168,12 +169,98 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isKPA) {
|
||||||
|
const kpa = amp.kpa;
|
||||||
|
return (
|
||||||
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || kpa.model || 'Elecraft'}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!kpa.connected}
|
||||||
|
onClick={() => AmpOperate(amp.id, !kpa.operate).catch(() => {})}
|
||||||
|
title={kpa.fault_text ? t('flxp.kpaClearsFault') : undefined}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
kpa.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{kpa.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
{/* The amplifier answers while its main supplies are off — a sleeping
|
||||||
|
microcontroller stays awake for exactly that — so ON is offered
|
||||||
|
over the network as well, unlike the SPE and Acom. */}
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
|
<button type="button" disabled={!kpa.connected}
|
||||||
|
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||||
|
<button type="button" disabled={!kpa.connected}
|
||||||
|
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', kpa.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', kpa.connected ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{kpa.connected ? (kpa.tuning ? t('flxp.kpaTuning') : (kpa.power_on ? 'ON' : 'OFF')) : t('flxp.kpaOffline')}
|
||||||
|
</span>
|
||||||
|
{kpa.connected && (
|
||||||
|
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||||
|
{kpa.band ? `${kpa.band} · ` : ''}{kpa.fwd_w}W · SWR {Number(kpa.swr ?? 0).toFixed(1)} · {kpa.temp_c}°C · {kpa.volt_v}V {kpa.cur_a}A
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{kpa.fault_text && (
|
||||||
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {kpa.fault_text}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{kpa.connected && (
|
||||||
|
<MeterBar label={t('flxp.outputPower')} value={Number(kpa.fwd_w) || 0} unit="W"
|
||||||
|
lo={0} hi={String(kpa.model).includes('500') ? 500 : 1500}
|
||||||
|
display={`${Number(kpa.fwd_w) || 0} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link.
|
// PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link.
|
||||||
const pg = amp.pgxl || {};
|
const pg = amp.pgxl || {};
|
||||||
const viaFlex = !!flex?.amp_available;
|
const viaFlex = !!flex?.amp_available;
|
||||||
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||||
const connected = !!pg.connected || viaFlex;
|
const connected = !!pg.connected || viaFlex;
|
||||||
const fault = flex?.amp_fault;
|
const fault = flex?.amp_fault;
|
||||||
|
|
||||||
|
// Meters built from the amplifier's own GSCP status frame, for when the radio
|
||||||
|
// is not feeding a meter stream.
|
||||||
|
//
|
||||||
|
// Whether there is power is the amp's state field; how much is the plain
|
||||||
|
// forward figure. NOT "peakfwd" — that is a latched maximum which is never
|
||||||
|
// reset and survives in the last-known status after the amp disconnects, so it
|
||||||
|
// once claimed 1350 W from an old transmission while 10 W was going out. Same
|
||||||
|
// reason peak_id is left alone. Both readings are gated on transmit so they
|
||||||
|
// fall back to zero between overs instead of freezing on the last one.
|
||||||
|
const pgxlMeters = () => {
|
||||||
|
if (!pg.connected) return null;
|
||||||
|
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : /TRANSMIT/i.test(pg.state || '');
|
||||||
|
const fwdW = peakHold('pgfwd', txing ? Number(pg.fwd_w) || 0 : 0);
|
||||||
|
const idA = peakHold('pgid', txing ? Number(pg.id) || 0 : 0);
|
||||||
|
const swr = peakHold('pgswr', txing ? Number(pg.vswr) || 0 : 0);
|
||||||
|
const tempC = Number(pg.temperature) || 0;
|
||||||
|
// Two columns, not four. The card sits beside a tall neighbour in Station
|
||||||
|
// Control, so a single row of four leaves the height empty and squeezes each
|
||||||
|
// bar into a quarter width — two rows of two use the room that is already
|
||||||
|
// there and give every bar twice the resolution.
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
|
<MeterBar label={t('flxp.outputPower')} value={fwdW} unit="W" lo={0} hi={2000}
|
||||||
|
display={`${Math.round(fwdW)} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
<MeterBar label={t('ampw.id')} value={idA} lo={0} hi={25} display={`${idA.toFixed(1)} A`} accent="#16a34a" />
|
||||||
|
{/* Below 1:1 the reading is meaningless, so an idle amp shows a flat bar
|
||||||
|
rather than a zero that looks like a perfect match. */}
|
||||||
|
<MeterBar label={t('ampw.swr')} value={swr >= 1 ? swr : 1} lo={1} hi={3}
|
||||||
|
display={swr >= 1 ? swr.toFixed(1) : '—'}
|
||||||
|
segColor={(f) => (f > 0.75 ? '#dc2626' : f > 0.4 ? '#f59e0b' : '#16a34a')} />
|
||||||
|
<MeterBar label={t('ampw.temp')} value={tempC} unit="°C" lo={0} hi={100}
|
||||||
|
display={tempC > 0 ? `${Math.round(tempC)} °C` : '—'}
|
||||||
|
segColor={(f) => (f > 0.8 ? '#dc2626' : f > 0.6 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
@@ -204,13 +291,20 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Amplifier meters (FWD / ID / TEMP …) from the FlexRadio UDP stream. */}
|
{/* Amplifier meters (FWD / ID / TEMP …).
|
||||||
{viaFlex && (() => {
|
The FlexRadio UDP stream is the preferred source — it is fast and reads
|
||||||
|
the same as SmartSDR. When there is no Flex, or it is not streaming,
|
||||||
|
the amplifier's OWN link carries the same figures; falling back to them
|
||||||
|
is what the docked widget already does. Without that fallback this card
|
||||||
|
showed an operator on a Kenwood nothing but OPERATE and the fan mode,
|
||||||
|
while the amplifier was reporting power, current and temperature all
|
||||||
|
along. */}
|
||||||
|
{(() => {
|
||||||
const meters = (flex?.meters as any[]) || [];
|
const meters = (flex?.meters as any[]) || [];
|
||||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
if (amps.length === 0) return null;
|
if (!viaFlex || amps.length === 0) return pgxlMeters();
|
||||||
// Power comes from the radio's meter stream and nothing else. The
|
// Power comes from the radio's meter stream and nothing else. The
|
||||||
// amplifier also reports a "peakfwd", and using it was a mistake twice
|
// amplifier also reports a "peakfwd", and using it was a mistake twice
|
||||||
// over: it is a latched maximum that is never reset, and it survives in
|
// over: it is a latched maximum that is never reset, and it survives in
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from
|
|||||||
// With several amplifiers configured the caller passes a selection ("all" or an
|
// With several amplifiers configured the caller passes a selection ("all" or an
|
||||||
// amp id) chosen from the toolbar icon's dropdown.
|
// amp id) chosen from the toolbar icon's dropdown.
|
||||||
|
|
||||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; kpa?: any; pgxl?: any };
|
||||||
|
|
||||||
// Full-scale watts for the output bar, from the model string.
|
// Full-scale watts for the output bar, from the model string.
|
||||||
function maxW(model?: string, fallback = 1300): number {
|
function maxW(model?: string, fallback = 1300): number {
|
||||||
@@ -92,27 +92,39 @@ function OperateButton({ operate, disabled, onClick, t }: {
|
|||||||
function AmpBlock({ amp, flex, showName, t }: {
|
function AmpBlock({ amp, flex, showName, t }: {
|
||||||
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
|
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
|
||||||
}) {
|
}) {
|
||||||
const spe = amp.spe, acom = amp.acom;
|
const spe = amp.spe, acom = amp.acom, kpaAmp = amp.kpa;
|
||||||
const hold = usePeakHold();
|
const hold = usePeakHold();
|
||||||
|
|
||||||
if (spe || acom) {
|
// One block for the three amplifiers OpsLog drives over its own link. They
|
||||||
const s = spe || acom;
|
// report the same handful of things under different names, so the differences
|
||||||
|
// are named here rather than spread through the markup.
|
||||||
|
if (spe || acom || kpaAmp) {
|
||||||
|
const s = spe || acom || kpaAmp;
|
||||||
// The amp reports zero watts on receive, so its TX flag clears the meter as
|
// The amp reports zero watts on receive, so its TX flag clears the meter as
|
||||||
// soon as the operator lets go — and the radio's own flag, when we have one,
|
// soon as the operator lets go — and the radio's own flag, when we have one,
|
||||||
// gets there first (the amp is polled on its own slower cycle).
|
// gets there first (the amp is polled on its own slower cycle).
|
||||||
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
|
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
|
||||||
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
|
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
|
||||||
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
|
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
|
||||||
const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
|
// The full-scale mark. A KPA500 reading against a 1500 W scale would look
|
||||||
|
// idle at full output, so the model decides it.
|
||||||
|
const hi = spe ? maxW(s.model)
|
||||||
|
: kpaAmp ? (String(s.model).includes('500') ? 500 : 1500)
|
||||||
|
: (Number(s.max_w) || 800);
|
||||||
// Power ON drives the remote-on control lines, so it stays available while
|
// Power ON drives the remote-on control lines, so it stays available while
|
||||||
// the amplifier is off and reporting nothing — but only over a serial link.
|
// the amplifier is off and reporting nothing — but only over a serial link.
|
||||||
const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
|
// A KPA answers ^ON while its main supplies are off — the sleeping
|
||||||
|
// microcontroller stays awake for exactly that — so power-on is available
|
||||||
|
// whenever the link itself is up, over serial and over the network alike.
|
||||||
|
const canPowerOn = spe ? (s.connected || s.transport === 'serial')
|
||||||
|
: kpaAmp ? !!s.connected
|
||||||
|
: (s.port_open && s.transport === 'serial');
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col gap-1.5">
|
<div className="h-full flex flex-col gap-1.5">
|
||||||
{showName && (
|
{showName && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
|
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : kpaAmp ? (s.model || 'KPA') : 'Acom')}</span>
|
||||||
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
|
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -155,11 +167,17 @@ function AmpBlock({ amp, flex, showName, t }: {
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||||
)}
|
)}
|
||||||
{(s.warnings || s.alarms || s.err_text) && (
|
{(s.warnings || s.alarms || s.err_text || s.fault_text) && (
|
||||||
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||||
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
|
{s.fault_text || s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Said where the fault is read, because it is the way out of it: on a
|
||||||
|
KPA, going to OPERATE clears the current fault — everything except
|
||||||
|
temperature, which clears by cooling. */}
|
||||||
|
{kpaAmp && s.fault_text && (
|
||||||
|
<div className="text-[9px] text-center text-muted-foreground">{t('ampw.kpaClear')}</div>
|
||||||
|
)}
|
||||||
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
|
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { EventsEmit } from '../../wailsjs/runtime/runtime';
|
||||||
|
import { GripVertical, Lock } from 'lucide-react';
|
||||||
|
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { RowColorSettings } from '@/lib/rowColors';
|
||||||
|
import {
|
||||||
|
MATRIX_VARS, applyMatrixColors, effectiveMatrixColor, emptyMatrixColors,
|
||||||
|
type MatrixColors,
|
||||||
|
} from '@/lib/matrixColors';
|
||||||
|
|
||||||
|
// A fixed palette plus a free picker. Muted values on purpose: they are
|
||||||
|
// composited at low opacity over a dark grid, where a saturated colour reads as
|
||||||
|
// an error state rather than a status.
|
||||||
|
const PALETTE = [
|
||||||
|
'#16a34a', '#0ea5e9', '#f59e0b', '#a855f7',
|
||||||
|
'#dc2626', '#14b8a6', '#eab308', '#ec4899',
|
||||||
|
'#64748b', '#84cc16', '#6366f1', '#f97316',
|
||||||
|
];
|
||||||
|
|
||||||
|
// The rule ids the backend orders; the labels live here so a translation never
|
||||||
|
// travels through the settings row.
|
||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
to_send: 'appr.ruleToSend',
|
||||||
|
confirmed: 'appr.ruleConfirmed',
|
||||||
|
sent: 'appr.ruleSent',
|
||||||
|
worked: 'appr.ruleWorked',
|
||||||
|
};
|
||||||
|
|
||||||
|
// The channels a rule can be scoped to. "worked" is the catch-all — it means
|
||||||
|
// nothing on ANY channel — so narrowing it would say nothing.
|
||||||
|
const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz', 'hamlog'] as const;
|
||||||
|
const CHANNEL_LABELS: Record<string, string> = {
|
||||||
|
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com', hamlog: 'HAMLOG.online',
|
||||||
|
};
|
||||||
|
|
||||||
|
// MatrixColorsSection recolours the band/mode matrix — the PH/CW/DIG grid in the
|
||||||
|
// Stats panel.
|
||||||
|
//
|
||||||
|
// The pickers are seeded from what the matrix is painting RIGHT NOW (the active
|
||||||
|
// theme's ramp, or an existing override), not from a fixed palette: the operator
|
||||||
|
// starts from the colours in front of them and moves one, instead of being
|
||||||
|
// handed six values that may belong to a theme they stopped using. Every change
|
||||||
|
// is applied to the live document at once, so the sample row below is the real
|
||||||
|
// thing rather than a mock-up of it.
|
||||||
|
function MatrixColorsSection() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [cfg, setCfg] = useState<MatrixColors | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setCfg((await GetMatrixColors()) as any);
|
||||||
|
} catch {
|
||||||
|
setCfg(emptyMatrixColors());
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = (next: MatrixColors) => {
|
||||||
|
setCfg(next);
|
||||||
|
applyMatrixColors(next); // live, before the round trip — the panel must not lag the choice
|
||||||
|
SaveMatrixColors(next as any).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Turning it ON with nothing stored would change nothing at all and read as a
|
||||||
|
// broken switch, so the empty slots are filled from the theme's current ramp:
|
||||||
|
// the operator sees six swatches that match the grid and edits from there.
|
||||||
|
const enable = (on: boolean) => {
|
||||||
|
if (!cfg) return;
|
||||||
|
if (!on) {
|
||||||
|
save({ ...cfg, enabled: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seeded = { ...cfg, enabled: true };
|
||||||
|
for (const { key, cssVar } of MATRIX_VARS) {
|
||||||
|
if (!String(seeded[key] ?? '').trim()) seeded[key] = effectiveMatrixColor(cssVar);
|
||||||
|
}
|
||||||
|
save(seeded);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset clears the overrides but keeps the section switched on, then re-seeds
|
||||||
|
// from the theme — "back to the theme's colours", which is what an operator
|
||||||
|
// means by reset here, rather than "switch the whole feature off".
|
||||||
|
const reset = () => {
|
||||||
|
if (!cfg) return;
|
||||||
|
applyMatrixColors({ ...emptyMatrixColors(), enabled: false });
|
||||||
|
const seeded = { ...emptyMatrixColors(), enabled: true };
|
||||||
|
for (const { key, cssVar } of MATRIX_VARS) seeded[key] = effectiveMatrixColor(cssVar);
|
||||||
|
save(seeded);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 border-t border-border/60 pt-4">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.enabled} className="mt-0.5" onCheckedChange={(c) => enable(!!c)} />
|
||||||
|
<span>
|
||||||
|
{t('appr.matrixEnable')}{' '}
|
||||||
|
<span className="text-xs text-muted-foreground">{t('appr.matrixHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cfg.enabled && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
|
{MATRIX_VARS.map(({ key, cssVar, label }) => (
|
||||||
|
<label key={key} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={String(cfg[key] || '').trim() || effectiveMatrixColor(cssVar)}
|
||||||
|
onChange={(e) => save({ ...cfg, [key]: e.target.value })}
|
||||||
|
className="size-6 rounded-md border border-border bg-transparent p-0 cursor-pointer shrink-0"
|
||||||
|
/>
|
||||||
|
{t(label)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The matrix as it will actually look: same tokens, same shapes. */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs text-muted-foreground w-10 shrink-0">{t('appr.matrixSample')}</span>
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-call-conf" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-call-work" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-dx-conf" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-dx-work" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-none" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" />
|
||||||
|
<span className="relative inline-block w-7 h-5 rounded bg-mx-dx-conf">
|
||||||
|
<span className="absolute top-[4px] right-[4px] size-[5px] rounded-full bg-mx-mark-work ring-1 ring-background" />
|
||||||
|
</span>
|
||||||
|
<span className="relative inline-block w-7 h-5 rounded bg-mx-dx-conf">
|
||||||
|
<span className="absolute top-[4px] right-[4px] size-[5px] rounded-full bg-mx-mark-conf ring-1 ring-background" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" onClick={reset}
|
||||||
|
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground">
|
||||||
|
{t('appr.matrixReset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppearancePanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [cfg, setCfg] = useState<RowColorSettings | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try { setCfg((await GetRowColors()) as any); } catch { /* defaults on the backend */ }
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = (next: RowColorSettings) => {
|
||||||
|
setCfg(next);
|
||||||
|
SaveRowColors(next as any).catch(() => {});
|
||||||
|
};
|
||||||
|
const patchRule = (id: string, patch: Partial<{ color: string; enabled: boolean; channels: string[] }>) => {
|
||||||
|
if (!cfg) return;
|
||||||
|
save({ ...cfg, rules: cfg.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// The rule card shows the row exactly as the grid will draw it, so the choice
|
||||||
|
// is made by looking rather than by imagining.
|
||||||
|
const preview = (color: string): Record<string, string> => {
|
||||||
|
const st = cfg?.style ?? 'bar';
|
||||||
|
const pct = cfg?.intensity ?? 12;
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (st === 'tint' || st === 'both') out.backgroundColor = `color-mix(in srgb, ${color} ${pct}%, transparent)`;
|
||||||
|
if (st === 'bar' || st === 'both') out.boxShadow = `inset 3px 0 0 ${color}`;
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cfg) return <div className="p-1 text-sm text-muted-foreground">…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!cfg.bandmap_lotw} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => save({ ...cfg, bandmap_lotw: !!c } as any)} />
|
||||||
|
<span>{t('appr.bandmapLotw')} <span className="text-xs text-muted-foreground">{t('appr.bandmapLotwHint')}</span></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Banding is its own thing, above the QSL rules and outside them: it says
|
||||||
|
nothing about a contact, it only helps the eye keep its line across a
|
||||||
|
wide table. Turning the status colours off must not take it away. */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={(cfg as any).recent_zebra !== 'off'} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => save({ ...cfg, recent_zebra: c ? '' : 'off' } as any)} />
|
||||||
|
<span>{t('appr.zebra')} <span className="text-xs text-muted-foreground">{t('appr.zebraHint')}</span></span>
|
||||||
|
</label>
|
||||||
|
{(cfg as any).recent_zebra !== 'off' && (
|
||||||
|
<div className="flex items-center gap-2 pl-6">
|
||||||
|
<span className="text-xs text-muted-foreground">{t('appr.zebraColor')}</span>
|
||||||
|
<input type="color" className="size-7 rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||||
|
value={/^#[0-9a-fA-F]{6}$/.test((cfg as any).recent_zebra_color ?? '') ? (cfg as any).recent_zebra_color : '#808080'}
|
||||||
|
onChange={(e) => save({ ...cfg, recent_zebra_color: e.target.value } as any)} />
|
||||||
|
{/* Empty means "follow the theme", and there has to be a way back to
|
||||||
|
it — a colour chosen under one theme is wrong under the other. */}
|
||||||
|
{(cfg as any).recent_zebra_color && (
|
||||||
|
<button type="button" className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => save({ ...cfg, recent_zebra_color: '' } as any)}>{t('appr.zebraAuto')}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.enabled} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} />
|
||||||
|
<span>{t('appr.enable')} <span className="text-xs text-muted-foreground">{t('appr.enableHint')}</span></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cfg.enabled && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Style first: it decides whether the colours below are a signal or a
|
||||||
|
wallpaper, which matters more than which hue they are. */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-sm">{t('appr.style')}</span>
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
||||||
|
{(['bar', 'tint', 'both'] as const).map((v) => (
|
||||||
|
<button key={v} type="button" onClick={() => save({ ...cfg, style: v })}
|
||||||
|
className={cn('px-3 py-1.5 font-medium', (cfg.style ?? 'bar') === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t('appr.style' + v[0].toUpperCase() + v.slice(1))}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(cfg.style ?? 'bar') !== 'bar' && (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
{t('appr.intensity')}
|
||||||
|
<input type="range" min={5} max={45} step={1} value={cfg.intensity ?? 12}
|
||||||
|
onChange={(e) => save({ ...cfg, intensity: parseInt(e.target.value, 10) })}
|
||||||
|
className="w-32 accent-[var(--primary)]" />
|
||||||
|
<span className="font-mono text-xs text-muted-foreground w-8">{cfg.intensity ?? 12}%</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Order matters and is shown: a contact is usually several of these at
|
||||||
|
once, and the first match wins. */}
|
||||||
|
<p className="text-xs text-muted-foreground">{t('appr.orderHint')}</p>
|
||||||
|
{cfg.rules.map((r, i) => (
|
||||||
|
<div key={r.id} className="rounded-lg border border-border/60 p-2.5 space-y-2"
|
||||||
|
style={r.enabled ? preview(r.color) : undefined}>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={r.enabled} onCheckedChange={(c) => patchRule(r.id, { enabled: !!c })} />
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{i + 1}.</span>
|
||||||
|
<span className="font-medium">{t(LABELS[r.id] ?? r.id)}</span>
|
||||||
|
</label>
|
||||||
|
{/* Which channels this category looks at. None ticked = all of
|
||||||
|
them, which is what an unnarrowed rule should mean. */}
|
||||||
|
{r.enabled && r.id !== 'worked' && (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap pl-6 text-xs">
|
||||||
|
{CHANNELS.map((c) => {
|
||||||
|
const on = !r.channels?.length || r.channels.includes(c);
|
||||||
|
return (
|
||||||
|
<label key={c} className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<Checkbox checked={on} onCheckedChange={(v) => {
|
||||||
|
const cur = r.channels?.length ? r.channels : [...CHANNELS];
|
||||||
|
const next = v ? [...new Set([...cur, c])] : cur.filter((x) => x !== c);
|
||||||
|
patchRule(r.id, { channels: next });
|
||||||
|
}} />
|
||||||
|
{CHANNEL_LABELS[c]?.startsWith('appr.') ? t(CHANNEL_LABELS[c]) : CHANNEL_LABELS[c]}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.enabled && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap pl-6">
|
||||||
|
{PALETTE.map((c) => (
|
||||||
|
<button key={c} type="button" title={c}
|
||||||
|
onClick={() => patchRule(r.id, { color: c })}
|
||||||
|
className={cn('size-6 rounded-md border-2 transition-transform hover:scale-110',
|
||||||
|
r.color.toLowerCase() === c ? 'border-foreground' : 'border-transparent')}
|
||||||
|
style={{ backgroundColor: c }} />
|
||||||
|
))}
|
||||||
|
<input type="color" value={r.color} title={t('appr.custom')}
|
||||||
|
onChange={(e) => patchRule(r.id, { color: e.target.value })}
|
||||||
|
className="size-6 rounded-md border border-border bg-transparent p-0 cursor-pointer" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MatrixColorsSection />
|
||||||
|
<WidgetOrderSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row of widgets to the right of the entry, in the order they appear.
|
||||||
|
//
|
||||||
|
// Flexbox does the moving in the main view — this list only decides the order
|
||||||
|
// property each one gets. That is why a widget switched OFF still holds its
|
||||||
|
// place here: it comes back where the operator left it rather than at the end.
|
||||||
|
export const WIDGET_KEYS = [
|
||||||
|
'livestations', 'chat', 'rotor', 'motorant', 'antgenius',
|
||||||
|
'amp', 'tuner', 'scp', 'chasenew', 'watchlist', 'dvk', 'winkeyer', 'photo',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const WIDGET_LABELS: Record<string, string> = {
|
||||||
|
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
|
||||||
|
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp',
|
||||||
|
tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew', watchlist: 'wo.watchlist',
|
||||||
|
dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
|
||||||
|
};
|
||||||
|
|
||||||
|
function readWidgetOrder(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('opslog.widgetOrder');
|
||||||
|
const arr = raw ? JSON.parse(raw) : null;
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
// A key from an older build that no longer exists is dropped; a widget
|
||||||
|
// added since joins the end. An old preference can never hide a new one.
|
||||||
|
const known = arr.filter((k: any) => (WIDGET_KEYS as readonly string[]).includes(k));
|
||||||
|
return [...known, ...WIDGET_KEYS.filter((k) => !known.includes(k))];
|
||||||
|
}
|
||||||
|
} catch { /* corrupt pref → the default order */ }
|
||||||
|
return [...WIDGET_KEYS];
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetOrderSection() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [order, setOrder] = useState<string[]>(readWidgetOrder);
|
||||||
|
const dragKey = useRef<string | null>(null);
|
||||||
|
const [dragging, setDragging] = useState<string | null>(null);
|
||||||
|
// Where the row would land. Drawn as a line above the target rather than by
|
||||||
|
// colouring it: the question a dragging hand asks is "between which two", and
|
||||||
|
// a highlighted row answers a different one.
|
||||||
|
const [over, setOver] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const commit = (keys: string[]) => {
|
||||||
|
setOrder(keys);
|
||||||
|
try { localStorage.setItem('opslog.widgetOrder', JSON.stringify(keys)); } catch { /* private mode */ }
|
||||||
|
// The main view listens: an order is meant to be watched as it is dragged,
|
||||||
|
// not discovered after closing Preferences.
|
||||||
|
EventsEmit('widgets:order', keys);
|
||||||
|
};
|
||||||
|
const moveTo = (from: string, to: string) => {
|
||||||
|
if (from === to) return;
|
||||||
|
const next = order.filter((k) => k !== from);
|
||||||
|
const at = next.indexOf(to);
|
||||||
|
next.splice(at < 0 ? next.length : at, 0, from);
|
||||||
|
commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold">{t('wo.title')}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('wo.hint')}</p>
|
||||||
|
<div className="space-y-1 max-w-md">
|
||||||
|
{/* The two that cannot move, shown so the order reads as the whole row
|
||||||
|
rather than as a list that mysteriously starts at the third item. */}
|
||||||
|
{['wo.entry', 'wo.details'].map((k) => (
|
||||||
|
<div key={k}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2 py-1.5 text-sm text-muted-foreground">
|
||||||
|
<Lock className="size-3.5 shrink-0 opacity-60" />
|
||||||
|
<span className="flex-1 min-w-0 truncate">{t(k)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{order.map((k) => (
|
||||||
|
// The WHOLE row is the handle, not the grip alone: a list whose rows
|
||||||
|
// can only be moved by a 16-pixel icon is a list most people conclude
|
||||||
|
// cannot be moved. The grip stays as the sign that it can.
|
||||||
|
<div key={k} draggable
|
||||||
|
onDragStart={(e) => { dragKey.current = k; setDragging(k); e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
|
onDragEnd={() => { dragKey.current = null; setDragging(null); setOver(null); }}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!dragKey.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (over !== k) setOver(k);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => { if (over === k) setOver(null); }}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (!dragKey.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
moveTo(dragKey.current, k);
|
||||||
|
setOver(null);
|
||||||
|
}}
|
||||||
|
title={t('wo.drag')}
|
||||||
|
className={cn('flex items-center gap-2 rounded-md border bg-card px-2 py-1.5 text-sm select-none',
|
||||||
|
'cursor-grab active:cursor-grabbing transition-shadow',
|
||||||
|
dragging === k ? 'opacity-50 border-primary shadow-lg' : 'border-border hover:border-foreground/30',
|
||||||
|
// The landing line, on the edge the row would take.
|
||||||
|
over === k && dragging !== k && 'shadow-[inset_0_3px_0_0_var(--primary)]')}>
|
||||||
|
<GripVertical className="size-4 shrink-0 text-muted-foreground/50" />
|
||||||
|
<span className="flex-1 min-w-0 truncate">{t(WIDGET_LABELS[k] ?? k)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => commit([...WIDGET_KEYS])}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground underline">
|
||||||
|
{t('wo.reset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle } from 'lucide-react';
|
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle, Share2 } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -14,12 +14,12 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import {
|
import {
|
||||||
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
||||||
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||||
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference,
|
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference, RenameAwardReference,
|
||||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||||
ExportAwardForCatalog,
|
ExportAwardForCatalog, ExportAward,
|
||||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
@@ -62,9 +62,14 @@ type Preset = { key: string; name: string; field: string; dxcc: number; refs: Aw
|
|||||||
// organizational only; matching is driven by the field/pattern/dynamic options,
|
// organizational only; matching is driven by the field/pattern/dynamic options,
|
||||||
// so there's no need for separate GRID/DXCC types (use QSOFIELDS + the field).
|
// so there's no need for separate GRID/DXCC types (use QSOFIELDS + the field).
|
||||||
const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
|
const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
|
||||||
|
// Sentinel for "no QSO field at all". The definition stores an empty field, and
|
||||||
|
// the matcher already treats that as "nothing to scan" — this only gives the
|
||||||
|
// operator a way to ASK for it.
|
||||||
|
const NO_FIELD = '__none__';
|
||||||
const CONFIRM_SRC = [
|
const CONFIRM_SRC = [
|
||||||
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'hamlog', label: 'HAMLOG.online' },
|
||||||
|
{ id: 'custom', label: 'Custom' },
|
||||||
];
|
];
|
||||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||||
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
||||||
@@ -97,6 +102,13 @@ function Chips({ all, value, onToggle }: { all: string[]; value: string[]; onTog
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Awards use a far-future date as "no end" (RDA carries 9999-12-31). Printing it
|
||||||
|
// back at the operator reads like a real deadline, so show it as open-ended.
|
||||||
|
function openEnded(d?: string): string {
|
||||||
|
if (!d) return '—';
|
||||||
|
return /^9\d{3}-/.test(d) ? '—' : d;
|
||||||
|
}
|
||||||
|
|
||||||
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
||||||
@@ -270,6 +282,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
}, [testRows]);
|
}, [testRows]);
|
||||||
|
const noField = !String(cur?.field ?? '').trim();
|
||||||
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
||||||
const toggleIn = (key: keyof AwardDef, v: string) => {
|
const toggleIn = (key: keyof AwardDef, v: string) => {
|
||||||
const arr = ((cur?.[key] as string[]) ?? []);
|
const arr = ((cur?.[key] as string[]) ?? []);
|
||||||
@@ -307,6 +320,23 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Export the SELECTED award on its own — the unit you actually share. The
|
||||||
|
// bundle above is a backup: sending it to someone hands over your whole
|
||||||
|
// catalogue when they asked for one award.
|
||||||
|
//
|
||||||
|
// Distinct from the catalog publish below, which is for shipping an award INTO
|
||||||
|
// OpsLog: that one stamps a version and clears user_edited. This one is a plain
|
||||||
|
// share and leaves both alone, so the recipient's copy is correctly marked as
|
||||||
|
// someone else's work rather than as a pristine built-in.
|
||||||
|
async function exportOne() {
|
||||||
|
setErr('');
|
||||||
|
if (!cur) return;
|
||||||
|
try {
|
||||||
|
const code = cur.code.trim().toUpperCase();
|
||||||
|
const p = await ExportAward(code);
|
||||||
|
if (p) setErr(t('awed.exportedOneTo', { code, path: p }));
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||||
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||||
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||||
@@ -373,7 +403,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
<DialogContent overlayBlur={false} className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||||
<DialogHeader className="px-5 py-3 border-b">
|
<DialogHeader className="px-5 py-3 border-b">
|
||||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -542,11 +572,30 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
||||||
<p className="text-[11px] text-muted-foreground">{t('awed.qsoParams')}</p>
|
<p className="text-[11px] text-muted-foreground">{t('awed.qsoParams')}</p>
|
||||||
<Field2 label={t('awed.searchInField')}>
|
<Field2 label={t('awed.searchInField')}>
|
||||||
<Select value={cur.field} onValueChange={(v) => patch({ field: v })}>
|
{/* NO_FIELD is a sentinel because the stored value is the
|
||||||
|
empty string and a Select cannot carry one — an empty
|
||||||
|
item value means "show the placeholder" to Radix. */}
|
||||||
|
{/* Picking "no field" clears the fallbacks too: it promises that
|
||||||
|
nothing is matched automatically, and an OR rule left behind goes
|
||||||
|
on scanning its own field — worse than a visible one, because the
|
||||||
|
panel stops showing it. */}
|
||||||
|
<Select value={cur.field || NO_FIELD}
|
||||||
|
onValueChange={(v) => patch(v === NO_FIELD ? { field: '', or_rules: [] } : { field: v })}>
|
||||||
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
<SelectContent className="max-h-72">
|
||||||
|
<SelectItem value={NO_FIELD}>{t('awed.fieldNone')}</SelectItem>
|
||||||
|
{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field2>
|
</Field2>
|
||||||
|
{/* An award whose references live in no ADIF field at all —
|
||||||
|
WWBOTA has no column anywhere — has nothing to match on,
|
||||||
|
and every control below would be a question with no
|
||||||
|
answer. Hiding them is the point: the references are the
|
||||||
|
ones the operator assigns to a QSO by hand. */}
|
||||||
|
{noField ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground pl-[128px]">{t('awed.fieldNoneHint')}</p>
|
||||||
|
) : (<>
|
||||||
<Field2 label={t('awed.matchBy')}>
|
<Field2 label={t('awed.matchBy')}>
|
||||||
<div className="flex items-center gap-3 text-xs">
|
<div className="flex items-center gap-3 text-xs">
|
||||||
{['code', 'description', 'pattern'].map((m) => (
|
{['code', 'description', 'pattern'].map((m) => (
|
||||||
@@ -565,10 +614,17 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Field2 label={t('awed.prefix')}><Input className="h-8 font-mono text-xs" value={cur.prefix ?? ''} onChange={(e) => patch({ prefix: e.target.value })} placeholder={t('awed.prefixPlaceholder')} title={t('awed.prefixTitle')} /></Field2>
|
<Field2 label={t('awed.prefix')}><Input className="h-8 font-mono text-xs" value={cur.prefix ?? ''} onChange={(e) => patch({ prefix: e.target.value })} placeholder={t('awed.prefixPlaceholder')} title={t('awed.prefixTitle')} /></Field2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</>)}
|
||||||
{/* Fallback searches: tried in order, only while nothing
|
{/* Fallback searches: tried in order, only while nothing
|
||||||
has matched yet — the first that hits wins (short-circuit),
|
has matched yet — the first that hits wins (short-circuit),
|
||||||
so a value already resolved by the primary rule isn't
|
so a value already resolved by the primary rule isn't
|
||||||
re-derived differently by a later one. */}
|
re-derived differently by a later one.
|
||||||
|
|
||||||
|
Shown even with no field when rules survive from an
|
||||||
|
imported definition: a rule that still runs must still
|
||||||
|
be visible, or the award finds references the panel
|
||||||
|
says it cannot. */}
|
||||||
|
{(!noField || (cur.or_rules?.length ?? 0) > 0) && (
|
||||||
<div className="border-t pt-2.5 space-y-2">
|
<div className="border-t pt-2.5 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
||||||
@@ -606,6 +662,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -761,6 +818,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<TabsContent value="refs" className="mt-0">
|
<TabsContent value="refs" className="mt-0">
|
||||||
<ReferencesPanel
|
<ReferencesPanel
|
||||||
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
||||||
|
awardValidFrom={cur.valid_from} awardValidTo={cur.valid_to}
|
||||||
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
||||||
onChanged={loadMeta} setErr={setErr}
|
onChanged={loadMeta} setErr={setErr}
|
||||||
/>
|
/>
|
||||||
@@ -780,6 +838,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
||||||
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Share just the selected one. Sitting next to the whole-catalogue
|
||||||
|
export because that is where an operator looks for it, and labelled
|
||||||
|
with the code so the two are never confused at a glance. */}
|
||||||
|
{cur && (
|
||||||
|
<Button variant="outline" onClick={exportOne} title={t('awed.exportOneTitle')}>
|
||||||
|
<Share2 className="size-3.5 mr-1" /> {t('awed.exportOne', { code: cur.code.trim().toUpperCase() })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
||||||
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -874,14 +940,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
// ReferencesPanel — manage the reference list of one award: search/list on the
|
// ReferencesPanel — manage the reference list of one award: search/list on the
|
||||||
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
||||||
// the online updater (POTA/SOTA/WWFF).
|
// the online updater (POTA/SOTA/WWFF).
|
||||||
function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChanged, setErr }: {
|
function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, onUpdateOnline, updating, onChanged, setErr }: {
|
||||||
code: string; presets: Preset[]; meta?: RefMeta;
|
code: string; presets: Preset[]; meta?: RefMeta; awardValidFrom?: string; awardValidTo?: string;
|
||||||
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [refs, setRefs] = useState<AwardRef[]>([]);
|
const [refs, setRefs] = useState<AwardRef[]>([]);
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [selCode, setSelCode] = useState<string | null>(null);
|
const [selCode, setSelCode] = useState<string | null>(null);
|
||||||
|
// The code as TYPED. The list and every patch key off selCode, so editing the
|
||||||
|
// code in place would make the editor lose the reference mid-edit; the draft
|
||||||
|
// is applied as a rename when the operator saves.
|
||||||
|
const [codeDraft, setCodeDraft] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [bulk, setBulk] = useState('');
|
const [bulk, setBulk] = useState('');
|
||||||
const [showBulk, setShowBulk] = useState(false);
|
const [showBulk, setShowBulk] = useState(false);
|
||||||
@@ -919,6 +989,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sel = refs.find((r) => r.code === selCode) || null;
|
const sel = refs.find((r) => r.code === selCode) || null;
|
||||||
|
useEffect(() => { setCodeDraft(selCode ?? ''); }, [selCode]);
|
||||||
// Large lists are already filtered by the server; small lists filter locally.
|
// Large lists are already filtered by the server; small lists filter locally.
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (large) return refs;
|
if (large) return refs;
|
||||||
@@ -932,6 +1003,27 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
try { await SaveAwardReference(code, r as any); load(); onChanged(); }
|
try { await SaveAwardReference(code, r as any); load(); onChanged(); }
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Save the selected reference, renumbering it first when the code was edited.
|
||||||
|
//
|
||||||
|
// The rename has to come first and has to be a rename: saving under the new
|
||||||
|
// code would simply create a second reference and leave the old one behind,
|
||||||
|
// which is how a list quietly grows duplicates.
|
||||||
|
async function saveSelected(r: AwardRef) {
|
||||||
|
const next = codeDraft.trim().toUpperCase();
|
||||||
|
if (!next) { setErr(t('awed.refCodeEmpty')); return; }
|
||||||
|
if (next !== r.code) {
|
||||||
|
try {
|
||||||
|
await RenameAwardReference(code, r.code, next);
|
||||||
|
} catch (e: any) {
|
||||||
|
// Most often the number is already taken by another reference. Said
|
||||||
|
// here rather than swallowed: the save has NOT happened.
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelCode(next);
|
||||||
|
}
|
||||||
|
await saveRef({ ...r, code: next });
|
||||||
|
}
|
||||||
async function addRef() {
|
async function addRef() {
|
||||||
const c = prompt(t('awed.newRefCodePrompt'))?.trim().toUpperCase();
|
const c = prompt(t('awed.newRefCodePrompt'))?.trim().toUpperCase();
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
@@ -1013,7 +1105,13 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input className="h-8 w-28 font-mono font-semibold" value={sel.code} readOnly />
|
{/* Editable, because a shipped list can be wrong about it: WAJA
|
||||||
|
went out carrying Japan's civil prefecture numbers instead of
|
||||||
|
the JARL's, and correcting that meant deleting all 47
|
||||||
|
references and importing a new list. */}
|
||||||
|
<Input className="h-8 w-28 font-mono font-semibold" value={codeDraft}
|
||||||
|
title={t('awed.refCodeTip')}
|
||||||
|
onChange={(e) => setCodeDraft(e.target.value)} />
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> {t('awed.valid')}</label>
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
||||||
@@ -1031,7 +1129,27 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
third-party list may carry the values — but they are not offered
|
third-party list may carry the values — but they are not offered
|
||||||
for editing until something actually reads them. */}
|
for editing until something actually reads them. */}
|
||||||
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
||||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
{/* This reference's own validity window. A reference is not forever:
|
||||||
|
a park is delisted, a district merged. A QSO made while it
|
||||||
|
existed still counts — it was a valid contact on the day — and
|
||||||
|
one made afterwards does not.
|
||||||
|
Left empty the award's own dates govern, which is why they show
|
||||||
|
as the placeholder: the operator can see what "empty" inherits
|
||||||
|
instead of having to remember. */}
|
||||||
|
<Field2 label={t('awed.refValidFrom')}>
|
||||||
|
<Input type="date" className="h-8 w-44" value={sel.valid_from ?? ''}
|
||||||
|
onChange={(e) => patchSel({ valid_from: e.target.value })} />
|
||||||
|
</Field2>
|
||||||
|
<Field2 label={t('awed.refValidTo')}>
|
||||||
|
<Input type="date" className="h-8 w-44" value={sel.valid_to ?? ''}
|
||||||
|
onChange={(e) => patchSel({ valid_to: e.target.value })} />
|
||||||
|
</Field2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{(awardValidFrom || awardValidTo)
|
||||||
|
? t('awed.refValidHintAward', { from: openEnded(awardValidFrom), to: openEnded(awardValidTo) })
|
||||||
|
: t('awed.refValidHint')}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveSelected(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { X, Plus, Loader2 } from 'lucide-react';
|
import { X, Plus, Loader2 } from 'lucide-react';
|
||||||
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta } from '../../wailsjs/go/main/App';
|
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta, AwardRefsNew, GetTrackedAwards } from '../../wailsjs/go/main/App';
|
||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||||
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean };
|
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean; valid?: boolean };
|
||||||
type Meta = { code: string; count: number; can_update: boolean };
|
type Meta = { code: string; count: number; can_update: boolean };
|
||||||
|
|
||||||
// Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…)
|
// Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…)
|
||||||
@@ -54,6 +54,34 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
|
|
||||||
const entries = value ? value.split(';').filter(Boolean) : [];
|
const entries = value ? value.split(';').filter(Boolean) : [];
|
||||||
|
|
||||||
|
// Which of the picked references have never been worked. Asked of the backend
|
||||||
|
// only when the list of references actually changes — a handful of times per
|
||||||
|
// QSO — and it answers from an in-memory set (see awardnew.go).
|
||||||
|
//
|
||||||
|
// The first question about an award may arrive before its set is built, in
|
||||||
|
// which case the answer is "pending" and carries no verdict for it: we ask
|
||||||
|
// again rather than badge on a guess. Attempts are capped so a logbook that
|
||||||
|
// never finishes building cannot leave a timer running for the session.
|
||||||
|
const [isNew, setIsNew] = useState<Record<string, boolean>>({});
|
||||||
|
const entriesKey = entries.join(';');
|
||||||
|
useEffect(() => {
|
||||||
|
if (!entriesKey) { setIsNew({}); return; }
|
||||||
|
let alive = true;
|
||||||
|
let timer: number | undefined;
|
||||||
|
let tries = 0;
|
||||||
|
const ask = () => {
|
||||||
|
AwardRefsNew(entriesKey.split(';'))
|
||||||
|
.then((r: any) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setIsNew(r?.new ?? {});
|
||||||
|
if (r?.pending && ++tries < 10) timer = window.setTimeout(ask, 700);
|
||||||
|
})
|
||||||
|
.catch(() => { if (alive) setIsNew({}); }); // no badge beats a wrong one
|
||||||
|
};
|
||||||
|
ask();
|
||||||
|
return () => { alive = false; if (timer) window.clearTimeout(timer); };
|
||||||
|
}, [entriesKey]);
|
||||||
|
|
||||||
// When the reference set is cleared (a QSO was logged or the call was reset),
|
// When the reference set is cleared (a QSO was logged or the call was reset),
|
||||||
// also blank the picker's own state — the search box and the highlighted /
|
// also blank the picker's own state — the search box and the highlighted /
|
||||||
// selected reference — so the Awards tab starts fresh for the next contact.
|
// selected reference — so the Awards tab starts fresh for the next contact.
|
||||||
@@ -66,11 +94,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
// The awards the operator FOLLOWS, exactly as the Awards tab reads them.
|
||||||
|
// This panel used to offer every award that existed, so a station chasing
|
||||||
|
// three of them was picking references out of a list of twenty.
|
||||||
|
const [tracked, setTracked] = useState<Set<string>>(new Set());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
|
Promise.all([GetAwardDefs(), GetAwardReferenceMeta(), GetTrackedAwards()])
|
||||||
.then(([d, m]) => {
|
.then(([d, m, tr]) => {
|
||||||
setDefs((d ?? []) as any);
|
setDefs((d ?? []) as any);
|
||||||
setMetas(Object.fromEntries(((m ?? []) as Meta[]).map((x) => [String(x.code).toUpperCase(), x])));
|
setMetas(Object.fromEntries(((m ?? []) as Meta[]).map((x) => [String(x.code).toUpperCase(), x])));
|
||||||
|
setTracked(new Set(((tr ?? []) as string[]).map((c) => String(c).toUpperCase())));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -83,6 +116,12 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
return defs.filter((d) => {
|
return defs.filter((d) => {
|
||||||
// Computed awards (field = dxcc/cqz/…) are derived automatically.
|
// Computed awards (field = dxcc/cqz/…) are derived automatically.
|
||||||
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
|
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
|
||||||
|
// Switched off in the award editor: not a candidate for anything.
|
||||||
|
if (d.valid === false) return false;
|
||||||
|
// Followed awards only, when a selection has been made. An empty
|
||||||
|
// selection means "all of them" — the same rule the Awards tab uses, so
|
||||||
|
// the two lists cannot disagree about what this station chases.
|
||||||
|
if (tracked.size > 0 && !tracked.has(String(d.code).toUpperCase())) return false;
|
||||||
const scope = d.dxcc_filter ?? [];
|
const scope = d.dxcc_filter ?? [];
|
||||||
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
||||||
// Offer the award even when its reference list isn't loaded yet: a custom
|
// Offer the award even when its reference list isn't loaded yet: a custom
|
||||||
@@ -94,7 +133,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
return true;
|
return true;
|
||||||
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
|
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
|
||||||
.sort((a, b) => a.code.localeCompare(b.code));
|
.sort((a, b) => a.code.localeCompare(b.code));
|
||||||
}, [defs, metas, dxcc]);
|
}, [defs, metas, dxcc, tracked]);
|
||||||
|
|
||||||
// Keep the selected award valid as the offered list changes with the call.
|
// Keep the selected award valid as the offered list changes with the call.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -256,7 +295,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => setSelectedEntry(selectedEntry === entry ? null : entry)}
|
onClick={() => setSelectedEntry(selectedEntry === entry ? null : entry)}
|
||||||
>
|
>
|
||||||
<span className="font-mono font-semibold flex-1 truncate">{entry}</span>
|
<span className="font-mono font-semibold truncate">{entry}</span>
|
||||||
|
{isNew[entry] && (
|
||||||
|
<span
|
||||||
|
title={t('awrs.newRefHint')}
|
||||||
|
className="shrink-0 px-1 rounded-sm text-[9px] leading-[14px] font-semibold tracking-wide bg-success-muted text-success border border-success-border"
|
||||||
|
>
|
||||||
|
{t('awrs.new')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1" />
|
||||||
<button
|
<button
|
||||||
className="shrink-0 hover:text-destructive"
|
className="shrink-0 hover:text-destructive"
|
||||||
onClick={(e) => { e.stopPropagation(); removeEntry(entry); }}
|
onClick={(e) => { e.stopPropagation(); removeEntry(entry); }}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { isQSLConfirmed } from '@/lib/qsl';
|
||||||
import { AwardEditor } from '@/components/AwardEditor';
|
import { AwardEditor } from '@/components/AwardEditor';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
@@ -28,7 +29,32 @@ type AwardResult = {
|
|||||||
type AwardStatRow = { label: string; cells: number[]; total: number; grand_total: number };
|
type AwardStatRow = { label: string; cells: number[]; total: number; grand_total: number };
|
||||||
type AwardStats = { code: string; bands: string[]; rows: AwardStatRow[] };
|
type AwardStats = { code: string; bands: string[]; rows: AwardStatRow[] };
|
||||||
|
|
||||||
// Fixed band columns for the matrix view (Log4OM-style).
|
// Every band the matrix can show, in the order they belong in.
|
||||||
|
//
|
||||||
|
// The columns are the intersection of this with what the award covers, so a
|
||||||
|
// band missing HERE cannot appear at all whatever the award says — which is how
|
||||||
|
// DDFM, a French award worked well into the microwaves, ended at 70cm with no
|
||||||
|
// 23cm column and no way to ask for one.
|
||||||
|
//
|
||||||
|
// Mirrors bandOrder in internal/award — the same list in frequency order. The
|
||||||
|
// two are kept apart because they answer different questions (that one sorts an
|
||||||
|
// award's own band counts, this one lays out columns), but a band added to one
|
||||||
|
// belongs in the other.
|
||||||
|
// The DXCC Challenge counts one point per CONFIRMED entity per band, over ten
|
||||||
|
// bands — and 60 m is NOT one of them. That is the whole of the gap an operator
|
||||||
|
// notices between OpsLog and LoTW: 3229 here against 3004 there, and 224 of the
|
||||||
|
// difference is the 60 m column.
|
||||||
|
//
|
||||||
|
// Worked out from the confirmed row rather than asked of the backend, because
|
||||||
|
// that row IS the answer: the Challenge is its sum over these ten bands.
|
||||||
|
const CHALLENGE_BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
||||||
|
|
||||||
|
const ALL_BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m',
|
||||||
|
'6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||||
|
|
||||||
|
// What to show when there is nothing to narrow it down: an award with no band
|
||||||
|
// list and no contacts yet. The classic set — the whole of ALL_BANDS would be
|
||||||
|
// twenty-nine columns of mostly nothing.
|
||||||
const GRID_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm'];
|
const GRID_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm'];
|
||||||
|
|
||||||
// Per-band status for a reference, highest first.
|
// Per-band status for a reference, highest first.
|
||||||
@@ -39,6 +65,19 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
|
|||||||
if (r.bands?.includes(band)) return 'worked';
|
if (r.bands?.includes(band)) return 'worked';
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
// slotsToConfirm counts the band-slots worked with this reference and not yet
|
||||||
|
// confirmed on any of them — the QSLs still outstanding, one per cell showing W.
|
||||||
|
//
|
||||||
|
// It is the difference the Challenge line makes visible in the aggregate (1832
|
||||||
|
// worked against 1554 confirmed) without saying WHERE it is. Counted over the
|
||||||
|
// bands actually on screen, so it always adds up to the columns in front of the
|
||||||
|
// operator rather than to a band set they filtered out.
|
||||||
|
function slotsToConfirm(r: AwardRef, bands: string[]): number {
|
||||||
|
let n = 0;
|
||||||
|
for (const b of bands) if (cellStatus(r, b) === 'worked') n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
const CELL_STYLE: Record<CellStatus, string> = {
|
const CELL_STYLE: Record<CellStatus, string> = {
|
||||||
validated: 'bg-success text-success-foreground',
|
validated: 'bg-success text-success-foreground',
|
||||||
confirmed: 'bg-warning text-warning-foreground',
|
confirmed: 'bg-warning text-warning-foreground',
|
||||||
@@ -63,9 +102,15 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
// scoped: the award is limited to one or more DXCC entities. Missing-reference
|
||||||
|
// detection only means anything for those — see the Missing refs button.
|
||||||
|
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[]; scoped?: boolean };
|
||||||
|
|
||||||
export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) {
|
export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||||
|
onEditQSO?: (id: number) => void;
|
||||||
|
onAwardsChanged?: () => void;
|
||||||
|
onPaperQSL?: (call: string) => void;
|
||||||
|
} = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||||
// Computed results are cached per award code — each award is scanned only the
|
// Computed results are cached per award code — each award is scanned only the
|
||||||
@@ -80,7 +125,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
const [refSearch, setRefSearch] = useState('');
|
const [refSearch, setRefSearch] = useState('');
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf' | 'slots_notconf'>('all');
|
||||||
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
|
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
|
||||||
// confirmed" is two questions at once, and answering only one of them is what
|
// confirmed" is two questions at once, and answering only one of them is what
|
||||||
// sends an operator to a spreadsheet.
|
// sends an operator to a spreadsheet.
|
||||||
@@ -149,7 +194,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
]);
|
]);
|
||||||
const follow = new Set(tracked);
|
const follow = new Set(tracked);
|
||||||
let list: AwardListItem[] = defs
|
let list: AwardListItem[] = defs
|
||||||
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
|
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [], scoped: (d.dxcc_filter ?? []).length > 0 }))
|
||||||
.sort((a, b) => a.code.localeCompare(b.code));
|
.sort((a, b) => a.code.localeCompare(b.code));
|
||||||
if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
|
if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
|
||||||
setAwardList(list);
|
setAwardList(list);
|
||||||
@@ -173,26 +218,46 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
useEffect(() => { if (selected) compute(selected); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [modeFilter]);
|
useEffect(() => { if (selected) compute(selected); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [modeFilter]);
|
||||||
|
|
||||||
// Bands relevant to the selected award, used by BOTH the grid and the stats
|
// Bands relevant to the selected award, used by BOTH the grid and the stats
|
||||||
// matrix so neither is padded with bands the award doesn't use. Rule: the
|
// matrix so neither is padded with bands nothing was worked on. Rule: the
|
||||||
// award's explicit valid_bands if it has any (e.g. WAPC = 40/20/15/10m); else
|
// bands this operator HAS contacts on for this award; failing that, the
|
||||||
// the bands the operator actually has contacts on (so DXCC, which has no band
|
// award's own list of permitted bands. Empty set = no basis at all → callers
|
||||||
// restriction, shows 160m–6m instead of empty VHF/UHF columns). Empty set =
|
// fall back to the classic columns.
|
||||||
// no basis yet → callers fall back to all bands.
|
|
||||||
const awardBands = useMemo(() => {
|
const awardBands = useMemo(() => {
|
||||||
const vb = (awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase());
|
// The bands with CONTACTS on them come first, whatever the award declares.
|
||||||
if (vb.length > 0) return new Set(vb);
|
//
|
||||||
const worked = (current?.bands ?? [])
|
// It used to take the declared list when there was one, which put up a
|
||||||
|
// column for every band the award permits — DDFM allows 6m and 70cm, so
|
||||||
|
// both stood there empty on a station that has never worked a French
|
||||||
|
// department on either. An always-blank column reads as a gap in the log
|
||||||
|
// rather than as a band the operator never tried.
|
||||||
|
const worked = new Set((current?.bands ?? [])
|
||||||
.filter((b) => (b.worked ?? 0) > 0)
|
.filter((b) => (b.worked ?? 0) > 0)
|
||||||
.map((b) => String(b.band).toLowerCase());
|
.map((b) => String(b.band).toLowerCase()));
|
||||||
return new Set(worked);
|
if (worked.size > 0) return worked;
|
||||||
|
// Nothing worked yet: the award's own list is the honest answer — it says
|
||||||
|
// what this award is played on, which is the only thing there is to show.
|
||||||
|
return new Set((awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase()));
|
||||||
}, [awardList, selected, current]);
|
}, [awardList, selected, current]);
|
||||||
|
|
||||||
const gridBands = useMemo(() => {
|
const gridBands = useMemo(() => {
|
||||||
if (awardBands.size === 0) return GRID_BANDS;
|
if (awardBands.size === 0) return GRID_BANDS;
|
||||||
const filtered = GRID_BANDS.filter((b) => awardBands.has(b));
|
// Filtered from ALL_BANDS, so a band the award covers gets its column even
|
||||||
|
// when it is one the classic set never listed.
|
||||||
|
const filtered = ALL_BANDS.filter((b) => awardBands.has(b));
|
||||||
return filtered.length ? filtered : GRID_BANDS;
|
return filtered.length ? filtered : GRID_BANDS;
|
||||||
}, [awardBands]);
|
}, [awardBands]);
|
||||||
|
|
||||||
|
const challenge = useMemo(() => {
|
||||||
|
if (!stats || selected.toUpperCase() !== 'DXCC') return null;
|
||||||
|
const row = stats.rows.find((r) => r.label === 'CONFIRMED');
|
||||||
|
if (!row) return null;
|
||||||
|
let total = 0;
|
||||||
|
stats.bands.forEach((b, i) => {
|
||||||
|
if (CHALLENGE_BANDS.includes(String(b).toLowerCase())) total += Number(row.cells[i]) || 0;
|
||||||
|
});
|
||||||
|
return total;
|
||||||
|
}, [stats, selected]);
|
||||||
|
|
||||||
// Stats rows to show: drop the mode-category rows (CW / DIGITAL / PHONE) the
|
// Stats rows to show: drop the mode-category rows (CW / DIGITAL / PHONE) the
|
||||||
// award doesn't cover. The "ALL" rows (no suffix) always show; a category row
|
// award doesn't cover. The "ALL" rows (no suffix) always show; a category row
|
||||||
// shows only when the award has no emission restriction or lists that emission.
|
// shows only when the award has no emission restriction or lists that emission.
|
||||||
@@ -252,6 +317,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
if (refFilter === 'worked' && !r.worked) return false;
|
if (refFilter === 'worked' && !r.worked) return false;
|
||||||
if (refFilter === 'notworked' && r.worked) return false;
|
if (refFilter === 'notworked' && r.worked) return false;
|
||||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||||
|
// Worked-not-confirmed by SLOT, not by reference: an entity confirmed on
|
||||||
|
// 20 m still has a 15 m contact waiting for its card, and every filter
|
||||||
|
// above answers "no" for it because the entity itself is confirmed.
|
||||||
|
if (refFilter === 'slots_notconf' && slotsToConfirm(r, gridBands) === 0) return false;
|
||||||
if (modeFilter !== 'all' && refFilter !== 'notworked') {
|
if (modeFilter !== 'all' && refFilter !== 'notworked') {
|
||||||
// A reference never worked has no mode, so "not worked" plus a mode is
|
// A reference never worked has no mode, so "not worked" plus a mode is
|
||||||
// a contradiction: the mode filter stands aside rather than emptying
|
// a contradiction: the mode filter stands aside rather than emptying
|
||||||
@@ -287,7 +356,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
}
|
}
|
||||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||||
});
|
});
|
||||||
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
|
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir, gridBands]);
|
||||||
|
|
||||||
|
// The gap itself, over whatever the other filters left on screen: the number
|
||||||
|
// of cells an operator would have to turn green to close it.
|
||||||
|
const slotGap = useMemo(
|
||||||
|
() => filteredRefs.reduce((n, r) => n + slotsToConfirm(r, gridBands), 0),
|
||||||
|
[filteredRefs, gridBands],
|
||||||
|
);
|
||||||
|
|
||||||
// The group column earns its width only when the list actually carries one
|
// The group column earns its width only when the list actually carries one
|
||||||
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
|
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
|
||||||
@@ -312,7 +388,13 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
{t('awp.rescan')}
|
{t('awp.rescan')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); onAwardsChanged?.(); }} />
|
{/* setRescanTick as well as setByCode. The grid was refreshed on save
|
||||||
|
and the statistics matrix was not, so changing which confirmations
|
||||||
|
count — LoTW only, LoTW plus cards — left the whole table showing
|
||||||
|
the previous rule's numbers with nothing to say they were stale.
|
||||||
|
That is how a setting comes to look as though it does nothing. */}
|
||||||
|
<AwardEditor open={editing} onClose={() => setEditing(false)}
|
||||||
|
onSaved={() => { setByCode({}); setRescanTick((t) => t + 1); loadList(); onAwardsChanged?.(); }} />
|
||||||
{/* Quick selector — scales when there are many awards. */}
|
{/* Quick selector — scales when there are many awards. */}
|
||||||
{awardList.length > 0 && (
|
{awardList.length > 0 && (
|
||||||
<div className="px-2 py-2 border-b border-border/40">
|
<div className="px-2 py-2 border-b border-border/40">
|
||||||
@@ -410,7 +492,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||||
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => (
|
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')], ['slots_notconf', t('awp.filterSlotsNotCfmd')]] as const).map(([k, label]) => (
|
||||||
<button key={k} onClick={() => setRefFilter(k)}
|
<button key={k} onClick={() => setRefFilter(k)}
|
||||||
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||||
{label}
|
{label}
|
||||||
@@ -426,13 +508,26 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||||
<button
|
{slotGap > 0 && (
|
||||||
onClick={() => setShowMissing(true)}
|
<span className="text-xs text-muted-foreground" title={t('awp.slotGapTip')}>
|
||||||
className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
|
· <span className="font-semibold text-foreground">{slotGap}</span> {t('awp.slotGap')}
|
||||||
title={t('awp.missingRefsTitle')}
|
</span>
|
||||||
>
|
)}
|
||||||
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
{/* Only for an award scoped to a DXCC entity. "In this award's
|
||||||
</button>
|
scope but with no reference" needs a scope to be in: on a
|
||||||
|
worldwide reference award — POTA, SOTA, IOTA, WWFF — every
|
||||||
|
contact anywhere would qualify, so the answer is always none.
|
||||||
|
The button used to be there regardless and could only ever
|
||||||
|
open a window saying it had found nothing. */}
|
||||||
|
{awardList.find((a) => a.code === selected)?.scoped && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMissing(true)}
|
||||||
|
className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
|
||||||
|
title={t('awp.missingRefsTitle')}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||||
@@ -458,8 +553,8 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
<tr className="bg-card">
|
<tr className="bg-card">
|
||||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.statistic')}</th>
|
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.statistic')}</th>
|
||||||
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
|
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
|
||||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.total')}</th>
|
<th className="py-1.5 px-2 font-medium border-b border-border text-center" title={t('awp.totalHint')}>{t('awp.total')}</th>
|
||||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.grand')}</th>
|
<th className="py-1.5 px-2 font-medium border-b border-border text-center" title={t('awp.grandHint')}>{t('awp.grand')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -478,6 +573,19 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
{challenge !== null && (
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td className="sticky left-0 bg-card py-1.5 pr-3 font-semibold whitespace-nowrap" title={t('awp.challengeHint')}>
|
||||||
|
{t('awp.challenge')}
|
||||||
|
</td>
|
||||||
|
<td colSpan={statsBandIdx.length} className="py-1.5 text-[11px] text-muted-foreground">
|
||||||
|
{t('awp.challengeBands')}
|
||||||
|
</td>
|
||||||
|
<td className="text-center py-1.5 px-2 font-mono font-bold text-success" colSpan={2}>{challenge}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -525,7 +633,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
|
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
|
||||||
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
||||||
<button
|
<button
|
||||||
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110',
|
||||||
|
// Chasing the slots still to confirm, the
|
||||||
|
// confirmed ones are context, not the answer:
|
||||||
|
// a row is kept for its W cells and its V
|
||||||
|
// cells would otherwise be the loudest thing
|
||||||
|
// on it. Faded rather than hidden — which
|
||||||
|
// band is already done is worth seeing.
|
||||||
|
refFilter === 'slots_notconf' && s !== 'worked' && 'opacity-25')}
|
||||||
title={t('awp.cellTitle', { ref: r.ref, band: b })}
|
title={t('awp.cellTitle', { ref: r.ref, band: b })}
|
||||||
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||||
>{CELL_LABEL[s]}</button>
|
>{CELL_LABEL[s]}</button>
|
||||||
@@ -593,7 +708,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cell && current && (
|
{cell && current && (
|
||||||
<CellQSOModal code={current.code} cell={cell} modeClass={modeFilter === 'all' ? '' : modeFilter} onClose={() => setCell(null)} />
|
<CellQSOModal code={current.code} cell={cell} modeClass={modeFilter === 'all' ? '' : modeFilter} onClose={() => setCell(null)} onPaperQSL={onPaperQSL} />
|
||||||
)}
|
)}
|
||||||
{showMissing && current && (
|
{showMissing && current && (
|
||||||
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
|
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
|
||||||
@@ -607,6 +722,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
// the missing reference (e.g. a department for DDFM).
|
// the missing reference (e.g. a department for DDFM).
|
||||||
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
|
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
|
||||||
|
|
||||||
|
// How many references the assign dropdown will put in the DOM at once. Above
|
||||||
|
// this a search box appears beside it and the rest are held back, counted.
|
||||||
|
const REF_MENU_MAX = 300;
|
||||||
|
|
||||||
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [qsos, setQsos] = useState<any[]>([]);
|
const [qsos, setQsos] = useState<any[]>([]);
|
||||||
@@ -615,6 +734,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
|
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||||
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
|
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
|
||||||
|
const [refSearch, setRefSearch] = useState('');
|
||||||
const [assignRef, setAssignRef] = useState('');
|
const [assignRef, setAssignRef] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
@@ -628,12 +748,38 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
useEffect(() => { load(); }, [code]);
|
useEffect(() => { load(); }, [code]);
|
||||||
// The award's reference list drives the "assign" dropdown (e.g. China provinces).
|
// The award's reference list drives the "assign" dropdown (e.g. China
|
||||||
|
// provinces) — fetched ONLY once there is something to assign it to.
|
||||||
|
//
|
||||||
|
// Missing-reference detection needs a DXCC scope, so a worldwide award like
|
||||||
|
// POTA always has zero rows here and says so. It was still loading every
|
||||||
|
// reference behind that message: on a log with the full POTA park list
|
||||||
|
// imported, tens of thousands of them, feeding a dropdown that could not be
|
||||||
|
// used for anything. That is the window freezing on a screen with nothing to
|
||||||
|
// offer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (loading || qsos.length === 0) { setRefs([]); return; }
|
||||||
ListAwardReferences(code)
|
ListAwardReferences(code)
|
||||||
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
|
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
|
||||||
.catch(() => setRefs([]));
|
.catch(() => setRefs([]));
|
||||||
}, [code]);
|
}, [code, loading, qsos.length]);
|
||||||
|
|
||||||
|
// What the assign dropdown actually renders. Bounded, and it says how many it
|
||||||
|
// is holding back rather than silently showing the first few hundred.
|
||||||
|
const shownRefs = useMemo(() => {
|
||||||
|
const q = refSearch.trim().toUpperCase();
|
||||||
|
const rows = q
|
||||||
|
? refs.filter((r) => r.code.includes(q) || r.name.toUpperCase().includes(q))
|
||||||
|
: refs;
|
||||||
|
return rows.slice(0, REF_MENU_MAX);
|
||||||
|
}, [refs, refSearch]);
|
||||||
|
const hiddenRefs = useMemo(() => {
|
||||||
|
const q = refSearch.trim().toUpperCase();
|
||||||
|
const total = q
|
||||||
|
? refs.filter((r) => r.code.includes(q) || r.name.toUpperCase().includes(q)).length
|
||||||
|
: refs.length;
|
||||||
|
return Math.max(0, total - shownRefs.length);
|
||||||
|
}, [refs, refSearch, shownRefs.length]);
|
||||||
|
|
||||||
const qthOf = (q: any) => String(q.qth || q.notes || '');
|
const qthOf = (q: any) => String(q.qth || q.notes || '');
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
@@ -710,14 +856,29 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
{/* Bulk-assign toolbar */}
|
{/* Bulk-assign toolbar */}
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
|
||||||
<span className="text-xs text-muted-foreground">{t('awp.selectedArrow', { n: sel.size })}</span>
|
<span className="text-xs text-muted-foreground">{t('awp.selectedArrow', { n: sel.size })}</span>
|
||||||
|
{/* A search box beside the dropdown, and a bounded list inside it.
|
||||||
|
A reference list can hold tens of thousands of entries (POTA
|
||||||
|
parks, Russian districts); every one of them as a menu item is
|
||||||
|
hundreds of thousands of DOM nodes and a window that stops
|
||||||
|
answering. Nobody scrolls to K-4521 anyway — they type it. */}
|
||||||
|
{refs.length > REF_MENU_MAX && (
|
||||||
|
<Input className="h-7 w-40 text-xs font-mono" value={refSearch}
|
||||||
|
placeholder={t('awp.filterReferences')}
|
||||||
|
onChange={(e) => setRefSearch(e.target.value)} />
|
||||||
|
)}
|
||||||
<Select value={assignRef} onValueChange={setAssignRef}>
|
<Select value={assignRef} onValueChange={setAssignRef}>
|
||||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder={t('awp.chooseReference')} /></SelectTrigger>
|
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder={t('awp.chooseReference')} /></SelectTrigger>
|
||||||
<SelectContent className="max-h-72">
|
<SelectContent className="max-h-72">
|
||||||
{refs.map((r) => (
|
{shownRefs.map((r) => (
|
||||||
<SelectItem key={r.code} value={r.code}>
|
<SelectItem key={r.code} value={r.code}>
|
||||||
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
|
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
{hiddenRefs > 0 && (
|
||||||
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
|
||||||
|
{t('awp.refsNarrow', { n: hiddenRefs })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
||||||
@@ -778,7 +939,14 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
|
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
|
||||||
function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; modeClass: string; onClose: () => void }) {
|
function CellQSOModal({ code, cell, modeClass, onClose, onPaperQSL }: {
|
||||||
|
code: string; cell: { ref: string; band: string; name?: string }; modeClass: string;
|
||||||
|
onClose: () => void;
|
||||||
|
// Send this callsign to the QSL Manager's Paper QSL view. Chasing a
|
||||||
|
// confirmation starts here — an unconfirmed entity, the contact behind it —
|
||||||
|
// and used to continue by retyping the callsign into another tab.
|
||||||
|
onPaperQSL?: (call: string) => void;
|
||||||
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [qsos, setQsos] = useState<any[]>([]);
|
const [qsos, setQsos] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -815,10 +983,18 @@ function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell:
|
|||||||
{qsos.map((q, i) => (
|
{qsos.map((q, i) => (
|
||||||
<tr key={q.id ?? i} className="border-b border-border/30">
|
<tr key={q.id ?? i} className="border-b border-border/30">
|
||||||
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
|
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
|
||||||
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td>
|
<td className="py-1 pr-2 font-mono font-semibold">
|
||||||
|
{onPaperQSL ? (
|
||||||
|
<button type="button" className="hover:underline text-primary"
|
||||||
|
title={t('awp.paperQslTip')}
|
||||||
|
onClick={() => { onPaperQSL(String(q.callsign ?? '')); onClose(); }}>
|
||||||
|
{q.callsign}
|
||||||
|
</button>
|
||||||
|
) : q.callsign}
|
||||||
|
</td>
|
||||||
<td className="py-1 pr-2">{q.band}</td>
|
<td className="py-1 pr-2">{q.band}</td>
|
||||||
<td className="py-1 pr-2">{q.mode}</td>
|
<td className="py-1 pr-2">{q.mode}</td>
|
||||||
<td className="py-1 pr-3 text-muted-foreground">{[q.lotw_rcvd === 'Y' && 'LoTW', q.qsl_rcvd === 'Y' && 'QSL', q.eqsl_rcvd === 'Y' && 'eQSL'].filter(Boolean).join(', ')}</td>
|
<td className="py-1 pr-3 text-muted-foreground">{[isQSLConfirmed(q.lotw_rcvd) && 'LoTW', isQSLConfirmed(q.qsl_rcvd) && 'QSL', isQSLConfirmed(q.eqsl_rcvd) && 'eQSL'].filter(Boolean).join(', ')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
|
||||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||||
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||||
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
||||||
@@ -24,6 +26,16 @@ interface Spot {
|
|||||||
band?: string;
|
band?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
spotter?: string;
|
spotter?: string;
|
||||||
|
// The park reference the POTA poller tagged this spot with. The map never
|
||||||
|
// reads it — the click handler upstream turns it into the QSO's POTA award
|
||||||
|
// reference — but it is declared so it cannot be lost by accident.
|
||||||
|
//
|
||||||
|
// It travelled only because the spot objects are passed through by reference
|
||||||
|
// and the type was erased. The first refactor to copy a spot field by field
|
||||||
|
// would have dropped the park silently, and the only symptom would be an
|
||||||
|
// activation logged without its reference. Same mistake as the status entry
|
||||||
|
// below, whose extra markers were arriving and undeclared.
|
||||||
|
pota_ref?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The FULL status entry, not the two fields the map used to declare. The extra
|
// The FULL status entry, not the two fields the map used to declare. The extra
|
||||||
@@ -38,6 +50,9 @@ type SpotStatusEntry = {
|
|||||||
new_county?: boolean;
|
new_county?: boolean;
|
||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
new_pfx?: boolean;
|
new_pfx?: boolean;
|
||||||
|
// Whether the station uploads to LoTW. Not a status — it says nothing about
|
||||||
|
// whether the spot is worth chasing — so it is drawn as a badge, never a colour.
|
||||||
|
lotw?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The extra markers are ORTHOGONAL to the entity status: a spot can be a worked
|
// The extra markers are ORTHOGONAL to the entity status: a spot can be a worked
|
||||||
@@ -62,6 +77,13 @@ const BMP_MARKER_LABEL: Record<string, string> = {
|
|||||||
new_pota: 'bmp.legendNewPota',
|
new_pota: 'bmp.legendNewPota',
|
||||||
new_county: 'bmp.legendNewCounty',
|
new_county: 'bmp.legendNewCounty',
|
||||||
worked_call: 'bmp.legendWorkedCall',
|
worked_call: 'bmp.legendWorkedCall',
|
||||||
|
// Not on the strip — the pill is 22 px and a fourth segment turns it into a
|
||||||
|
// colour code nobody reads — but the TOOLTIP has room, and leaving them out of
|
||||||
|
// it made the two panels contradict each other: the cluster said NEW PFX about
|
||||||
|
// a spot the map called "Worked". Both were true (the entity is worked on this
|
||||||
|
// slot, the WPX prefix never has been) and neither view said so.
|
||||||
|
new_pfx: 'clg2.newPfx',
|
||||||
|
new_grid: 'clg2.newGrid',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -79,65 +101,40 @@ interface Props {
|
|||||||
// globally from the band-map tab toolbar.
|
// globally from the band-map tab toolbar.
|
||||||
hideDigital?: boolean;
|
hideDigital?: boolean;
|
||||||
fitToBand?: boolean;
|
fitToBand?: boolean;
|
||||||
|
// onToggleFit and onToggleHideDigital add the two switches to this map's own
|
||||||
|
// header. Only the docked map passes them: the Band Map tab has both in its
|
||||||
|
// toolbar, above maps that all obey them at once, and a switch inside each
|
||||||
|
// card as well would be four ways to change one setting.
|
||||||
|
onToggleFit?: () => void;
|
||||||
|
onToggleHideDigital?: () => void;
|
||||||
|
// Mark stations that upload to LoTW (Settings → Appearance).
|
||||||
|
showLotw?: boolean;
|
||||||
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
|
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
|
||||||
// frequency (and tune to it). Only the docked Main-view band map sets this, so
|
// frequency (and tune to it). Only the docked Main-view band map sets this, so
|
||||||
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
|
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
|
||||||
keyNav?: boolean;
|
keyNav?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BAND_RANGES: Record<string, [number, number]> = {
|
// Band edges and mode segments come from lib/bandplan, which knows the
|
||||||
'160m': [1800, 2000],
|
// operator's IARU region (Settings → General): a Region 2 operator's 40 m runs
|
||||||
'80m': [3500, 3800],
|
// to 7300 with phone from 7125, and drawing Region 1's plan under their spots
|
||||||
'60m': [5350, 5450],
|
// cut the top 100 kHz of the band off the map.
|
||||||
'40m': [7000, 7200],
|
//
|
||||||
'30m': [10100, 10150],
|
// Sub-band shading colours: these are IDENTITIES (which mode the segment is
|
||||||
'20m': [14000, 14350],
|
// for), not states, so they take categorical hues — never the status tokens.
|
||||||
'17m': [18068, 18168],
|
// All three are drawn from the cool end of the categorical order and laid down
|
||||||
'15m': [21000, 21450],
|
// at low opacity, so the band plan stays background context while the warm
|
||||||
'12m': [24890, 24990],
|
// spot pills keep the foreground. Checked with the palette validator in both
|
||||||
'10m': [28000, 29700],
|
// themes (violet failed the dark protan step; magenta clears every check).
|
||||||
'6m': [50000, 50500],
|
const SEG_COLOR: Record<SegMode, string> = {
|
||||||
'4m': [70000, 70500],
|
cw: 'var(--chart-7)', // magenta
|
||||||
'2m': [144000, 146000],
|
digi: 'var(--chart-1)', // blue
|
||||||
'70cm': [430000, 440000],
|
phone: 'var(--chart-2)', // aqua
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sub-band shading: CW / digital / phone.
|
|
||||||
//
|
|
||||||
// These are IDENTITIES (which mode the segment is for), not states, so they take
|
|
||||||
// categorical hues — never the status tokens. They used to use success / info /
|
|
||||||
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
|
|
||||||
// is "new band" in this very component's legend, so the whole SSB portion read as
|
|
||||||
// a giant "new band" wash. Status colours are reserved for status.
|
|
||||||
//
|
|
||||||
// All three are drawn from the COOL end of the categorical order and laid down at
|
|
||||||
// low opacity, so the band plan stays background context while the warm spot pills
|
|
||||||
// keep the foreground to themselves.
|
|
||||||
// Checked with the palette validator in BOTH themes. Violet was the first pick
|
|
||||||
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
|
|
||||||
// protan reader there, i.e. the same colour. Magenta clears every check on both
|
|
||||||
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
|
|
||||||
const SEG_CW = 'var(--chart-7)'; // magenta
|
|
||||||
const SEG_DIGI = 'var(--chart-1)'; // blue
|
|
||||||
const SEG_PHONE = 'var(--chart-2)'; // aqua
|
|
||||||
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
||||||
// are read on top of it.
|
// are read on top of it.
|
||||||
const SEG_OPACITY = 0.13;
|
const SEG_OPACITY = 0.13;
|
||||||
|
|
||||||
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
|
||||||
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
|
|
||||||
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
|
|
||||||
'60m': [[5350, 5450, SEG_PHONE]],
|
|
||||||
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
|
|
||||||
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
|
|
||||||
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
|
|
||||||
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
|
|
||||||
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
|
|
||||||
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
|
|
||||||
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
|
|
||||||
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Small coloured dot + label used in the band-map legend strip.
|
// Small coloured dot + label used in the band-map legend strip.
|
||||||
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -153,6 +150,7 @@ function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; labe
|
|||||||
function statusLabel(s: string, t: (k: string) => string): string {
|
function statusLabel(s: string, t: (k: string) => string): string {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'new': return t('bmp.statusNew');
|
case 'new': return t('bmp.statusNew');
|
||||||
|
case 'new-band-mode': return t('bmp.statusNewBandMode');
|
||||||
case 'new-band': return t('bmp.statusNewBand');
|
case 'new-band': return t('bmp.statusNewBand');
|
||||||
case 'new-mode': return t('bmp.statusNewMode');
|
case 'new-mode': return t('bmp.statusNewMode');
|
||||||
case 'new-slot': return t('bmp.statusNewSlot');
|
case 'new-slot': return t('bmp.statusNewSlot');
|
||||||
@@ -180,7 +178,11 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
|
|||||||
// line = SVG leader stroke (visible on hover)
|
// line = SVG leader stroke (visible on hover)
|
||||||
// dot = small marker on the freq scale
|
// dot = small marker on the freq scale
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'new': return {
|
// NEW and NEW BAND+MODE share the strongest colour: both mean "there is
|
||||||
|
// something here you have never had", and a sixth shade in a palette read at
|
||||||
|
// a glance is a shade nobody can name.
|
||||||
|
case 'new':
|
||||||
|
case 'new-band-mode': return {
|
||||||
pill: 'bg-danger-muted text-danger-muted-foreground border-danger-border hover:bg-danger-muted',
|
pill: 'bg-danger-muted text-danger-muted-foreground border-danger-border hover:bg-danger-muted',
|
||||||
bar: 'bg-danger',
|
bar: 'bg-danger',
|
||||||
line: 'stroke-danger',
|
line: 'stroke-danger',
|
||||||
@@ -212,7 +214,37 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
|
|||||||
// Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot
|
// Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot
|
||||||
// every 2.75 kHz fits without anti-overlap kicking in — comfortable for
|
// every 2.75 kHz fits without anti-overlap kicking in — comfortable for
|
||||||
// most bands. Higher levels are for fine inspection of crowded sub-bands.
|
// most bands. Higher levels are for fine inspection of crowded sub-bands.
|
||||||
const PX_PER_KHZ = [8, 16, 32, 64, 128, 256];
|
// Zoom steps, px per kHz. 2 and 4 sit below the old floor of 8 so a whole band
|
||||||
|
// fits without FIT taking the scale away from you: 20 m end to end is 700 px at
|
||||||
|
// 2 px/kHz, which scrolls in one screen on most displays.
|
||||||
|
const PX_PER_KHZ = [2, 4, 8, 16, 32, 64, 128, 256];
|
||||||
|
const DEFAULT_ZOOM_IDX = PX_PER_KHZ.indexOf(32);
|
||||||
|
|
||||||
|
// Zoom is remembered PER BAND, in ONE json key rather than one key per band:
|
||||||
|
// lib/uiPref keeps an explicit list of the preferences that travel with data/,
|
||||||
|
// and thirteen entries there to say the same thing would be thirteen chances to
|
||||||
|
// forget one.
|
||||||
|
const ZOOM_KEY = 'opslog.bandMapZoom';
|
||||||
|
|
||||||
|
function readZoomMap(): Record<string, number> {
|
||||||
|
try {
|
||||||
|
const m = JSON.parse(localStorage.getItem(ZOOM_KEY) || '{}');
|
||||||
|
return m && typeof m === 'object' ? m : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readZoom(band: string): number {
|
||||||
|
const n = readZoomMap()[band];
|
||||||
|
return Number.isInteger(n) && n >= 0 && n < PX_PER_KHZ.length ? n : DEFAULT_ZOOM_IDX;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeZoom(band: string, idx: number): void {
|
||||||
|
const m = readZoomMap();
|
||||||
|
m[band] = idx;
|
||||||
|
writeUiPref(ZOOM_KEY, JSON.stringify(m));
|
||||||
|
}
|
||||||
const SCALE_W = 56;
|
const SCALE_W = 56;
|
||||||
const PILL_H = 22; // px — height of each callsign pill
|
const PILL_H = 22; // px — height of each callsign pill
|
||||||
const PILL_GAP = 32; // px between scale border and first pill (room for leader)
|
const PILL_GAP = 32; // px between scale border and first pill (room for leader)
|
||||||
@@ -227,7 +259,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
|||||||
// last; ties broken by closeness to the rig freq).
|
// last; ties broken by closeness to the rig freq).
|
||||||
const MAX_VISIBLE_SPOTS = 30;
|
const MAX_VISIBLE_SPOTS = 30;
|
||||||
|
|
||||||
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false }: Props) {
|
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, onToggleFit, onToggleHideDigital, keyNav = false, showLotw = false }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
// The two display options are applied ONCE here, on the whole map, so the
|
// The two display options are applied ONCE here, on the whole map, so the
|
||||||
@@ -240,14 +272,43 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
// nothing until the next poll happened to hand over a fresh object.
|
// nothing until the next poll happened to hand over a fresh object.
|
||||||
const dispOpts = readSpotDisplayOptions();
|
const dispOpts = readSpotDisplayOptions();
|
||||||
const spotStatus = useMemo(() => {
|
const spotStatus = useMemo(() => {
|
||||||
if (!dispOpts.muteWorked && !dispOpts.slotHighlight) return spotStatusRaw;
|
if (!dispOpts.muteWorked && !dispOpts.slotHighlight && dispOpts.chasePota) return spotStatusRaw;
|
||||||
const out: Record<string, SpotStatusEntry> = {};
|
const out: Record<string, SpotStatusEntry> = {};
|
||||||
for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
|
for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
|
||||||
return out;
|
return out;
|
||||||
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
|
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight, dispOpts.chasePota]);
|
||||||
const range = BAND_RANGES[band];
|
// Re-render when the operator changes their IARU region in Settings.
|
||||||
const segments = SEGMENT_COLORS[band] ?? [];
|
const [, setRegionTick] = useState(0);
|
||||||
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
|
useEffect(() => subscribeIaruRegion(() => setRegionTick((n) => n + 1)), []);
|
||||||
|
const range = bandRange(band);
|
||||||
|
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
|
||||||
|
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
||||||
|
// The legend is a reference, not a running display: once its colours are
|
||||||
|
// learnt it is four lines of a short screen spent saying nothing new. Folded
|
||||||
|
// away by a toggle, and the choice is remembered.
|
||||||
|
const [legendOpen, setLegendOpen] = useState(() => {
|
||||||
|
try { return localStorage.getItem('opslog.bmpLegend') !== '0'; } catch { return true; }
|
||||||
|
});
|
||||||
|
const toggleLegend = () => setLegendOpen((v) => {
|
||||||
|
const next = !v;
|
||||||
|
try { localStorage.setItem('opslog.bmpLegend', next ? '1' : '0'); } catch { /* private mode */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// The docked map follows the rig, so a band change must bring up THAT band's
|
||||||
|
// remembered zoom.
|
||||||
|
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
|
||||||
|
// Stored from the interaction, not from an effect on zoomIdx: an effect would
|
||||||
|
// also fire on the band change above, and in the same commit it would still be
|
||||||
|
// holding the PREVIOUS band's index — writing it over the new band's. Doing it
|
||||||
|
// where the operator actually turns the wheel keeps the two apart. (Running
|
||||||
|
// twice under StrictMode is harmless: the same value is stored.)
|
||||||
|
const changeZoom = useCallback((delta: number) => {
|
||||||
|
setZoomIdx((z) => {
|
||||||
|
const n = Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + delta));
|
||||||
|
if (n !== z) writeZoom(band, n);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}, [band]);
|
||||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [containerH, setContainerH] = useState(400);
|
const [containerH, setContainerH] = useState(400);
|
||||||
|
|
||||||
@@ -310,6 +371,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||||
switch (spotStatus[k]?.status ?? '') {
|
switch (spotStatus[k]?.status ?? '') {
|
||||||
case 'new': return 0;
|
case 'new': return 0;
|
||||||
|
case 'new-band-mode': return 0;
|
||||||
case 'new-band': return 1;
|
case 'new-band': return 1;
|
||||||
case 'new-slot': return 2;
|
case 'new-slot': return 2;
|
||||||
case 'new-call': return 2;
|
case 'new-call': return 2;
|
||||||
@@ -409,19 +471,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
||||||
|
|
||||||
useEffect(() => {
|
// No ctrl+wheel zoom here any more: ctrl+wheel is the WINDOW zoom everywhere
|
||||||
const el = scrollerRef.current;
|
// else in OpsLog (View ▸ Zoom in/out), and one gesture that resizes the whole
|
||||||
if (!el) return;
|
// app over one panel and one band map over another is a gesture nobody can
|
||||||
const onWheel = (e: WheelEvent) => {
|
// trust. The + / − buttons keep the zoom, deliberately and visibly.
|
||||||
if (!range) return;
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
setZoomIdx((z) => Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + (e.deltaY > 0 ? -1 : 1))));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
|
||||||
return () => el.removeEventListener('wheel', onWheel);
|
|
||||||
}, [range]);
|
|
||||||
|
|
||||||
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
||||||
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
||||||
@@ -471,8 +524,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
|
|
||||||
// Tick step (where small marks land) and label step (where the kHz
|
// Tick step (where small marks land) and label step (where the kHz
|
||||||
// number is printed) are decoupled so the scale shows ~10-20 numeric
|
// number is printed) are decoupled so the scale shows ~10-20 numeric
|
||||||
// labels per viewport regardless of zoom — at base 8 px/kHz we want
|
// labels per viewport regardless of zoom — at 8 px/kHz we want labels
|
||||||
// labels every 25 kHz, not every 250.
|
// every 25 kHz, not every 250. The bare values below are the floor, and
|
||||||
|
// they are what the 2 px/kHz step lands on: a whole band in one screen
|
||||||
|
// wants a number every 100 kHz, not every 25.
|
||||||
let tickStep = 50;
|
let tickStep = 50;
|
||||||
let labelStep = 100;
|
let labelStep = 100;
|
||||||
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
|
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
|
||||||
@@ -497,18 +552,48 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
return (
|
return (
|
||||||
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
||||||
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
|
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
|
||||||
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
|
{/* The band alone. "Map · 20M" spent a third of a narrow header saying
|
||||||
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
|
what the panel obviously is, and with four band maps side by side it
|
||||||
|
was four times the same word. */}
|
||||||
|
<span className="flex-1 min-w-0 truncate">{band}</span>
|
||||||
|
<button type="button" onClick={() => changeZoom(-1)} disabled={fitToBand || zoomIdx === 0}
|
||||||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
title={t('bmp.zoomOut')}>
|
title={t('bmp.zoomOut')}>
|
||||||
<Minus className="size-3" />
|
<Minus className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
|
{/* A readout, not a switch.
|
||||||
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
|
||||||
|
It WAS the switch for a day: clicking the px/kHz value toggled
|
||||||
|
fit-to-band, and in fit it read "FIT". Nobody found it — a control
|
||||||
|
whose label is a measurement does not look like a control, and once
|
||||||
|
switched off there was no longer anything on screen saying the option
|
||||||
|
existed. The switches below say what they are. */}
|
||||||
|
{/* In fit mode the readout has nothing left to say — the lit FIT button
|
||||||
|
says it, and printing the word twice side by side is just noise. The
|
||||||
|
Band Map tab's cards have no button, so there the readout keeps
|
||||||
|
reporting both states. */}
|
||||||
|
{(!fitToBand || !onToggleFit) && (
|
||||||
|
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => changeZoom(1)} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
||||||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
title={t('bmp.zoomIn')}>
|
title={t('bmp.zoomIn')}>
|
||||||
<Plus className="size-3" />
|
<Plus className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
|
{onToggleFit && (
|
||||||
|
<button type="button" onClick={onToggleFit} title={t('bmp.fitTitle')}
|
||||||
|
className={cn('shrink-0 text-[10px] font-semibold normal-case tracking-normal px-1 rounded border transition-colors',
|
||||||
|
fitToBand ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t('bmp.fit')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onToggleHideDigital && (
|
||||||
|
<button type="button" onClick={onToggleHideDigital} title={t('bmp.hideFtTitle')}
|
||||||
|
className={cn('shrink-0 text-[10px] font-semibold normal-case tracking-normal px-1 rounded border transition-colors',
|
||||||
|
hideDigital ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t('bmp.ftx')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" onClick={recenterOnRig}
|
<button type="button" onClick={recenterOnRig}
|
||||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||||
title={t('bmp.scrollToRig')}>
|
title={t('bmp.scrollToRig')}>
|
||||||
@@ -632,7 +717,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
'hover:translate-x-0.5 hover:shadow',
|
'hover:translate-x-0.5 hover:shadow',
|
||||||
style.pill,
|
style.pill,
|
||||||
)}
|
)}
|
||||||
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${markersFor(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${activeMarkers(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||||
>
|
>
|
||||||
{/* Left accent strip. With no extra marker it repeats the status
|
{/* Left accent strip. With no extra marker it repeats the status
|
||||||
colour, exactly as before; otherwise it splits into one
|
colour, exactly as before; otherwise it splits into one
|
||||||
@@ -649,6 +734,15 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
})()}
|
})()}
|
||||||
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
|
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
|
||||||
<span>{p.spot.dx_call}</span>
|
<span>{p.spot.dx_call}</span>
|
||||||
|
{/* Same badge the cluster list draws, for the same reason: the
|
||||||
|
muted-blue of a confirmation, never a status colour —
|
||||||
|
whether a station uploads to LoTW says nothing about
|
||||||
|
whether the spot is worth chasing. A glyph rather than a
|
||||||
|
colour also keeps it off the channel the statuses use. */}
|
||||||
|
{showLotw && entry?.lotw && (
|
||||||
|
<span title={t('clu.lotwBadge')} className="text-[9px] font-normal rounded px-1 py-px"
|
||||||
|
style={{ background: 'color-mix(in srgb, var(--info) 22%, transparent)', color: 'var(--info)' }}>L</span>
|
||||||
|
)}
|
||||||
{mode && (
|
{mode && (
|
||||||
<span className="text-[9px] font-normal text-current/70 bg-current/10 rounded px-1 py-px">
|
<span className="text-[9px] font-normal text-current/70 bg-current/10 rounded px-1 py-px">
|
||||||
{mode}
|
{mode}
|
||||||
@@ -661,6 +755,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Colour legend — what each pill colour means. */}
|
{/* Colour legend — what each pill colour means. */}
|
||||||
|
{legendOpen && (
|
||||||
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
||||||
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||||
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||||
@@ -673,13 +768,22 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
))}
|
))}
|
||||||
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
||||||
<span className="mx-0.5 opacity-40">|</span>
|
<span className="mx-0.5 opacity-40">|</span>
|
||||||
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
|
<LegendDot colour={SEG_COLOR.cw} label={t("bmp.legendCW")} />
|
||||||
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
|
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
|
||||||
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
|
<LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
)}
|
||||||
{t('bmp.footerHint')}
|
<div className="px-3 py-1 flex items-center gap-2 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono shrink-0">
|
||||||
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
<span className="flex-1 text-center">
|
||||||
|
{t('bmp.footerHint')}
|
||||||
|
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={toggleLegend}
|
||||||
|
title={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
|
||||||
|
aria-label={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
|
||||||
|
className="shrink-0 inline-flex items-center gap-0.5 rounded px-1 py-px hover:bg-muted hover:text-foreground">
|
||||||
|
{legendOpen ? <ChevronDown className="size-3" /> : <ChevronUp className="size-3" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { sunTimes } from '@/lib/sun';
|
import { sunTimes } from '@/lib/sun';
|
||||||
|
import { isQSLConfirmed } from '@/lib/qsl';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
|
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
@@ -13,7 +15,10 @@ interface Props {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
currentBand: string;
|
currentBand: string;
|
||||||
currentMode: string;
|
currentMode: string;
|
||||||
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
bands?: string[];
|
||||||
|
// The operator's configured mode list, in THEIR order: the digital row
|
||||||
|
// rotates through it.
|
||||||
|
modes?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
||||||
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
||||||
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
||||||
// to an entity with no position at all, and the block simply does not appear.
|
// to an entity with no position at all, and the block simply does not appear.
|
||||||
@@ -55,7 +60,11 @@ const DEFAULT_BANDS: { tag: string; label: string }[] = [
|
|||||||
];
|
];
|
||||||
const CLASSES = ['PH', 'CW', 'DIG'] as const;
|
const CLASSES = ['PH', 'CW', 'DIG'] as const;
|
||||||
|
|
||||||
const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DIGITALVOICE','PHONE']);
|
export const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DIGITALVOICE','PHONE']);
|
||||||
|
|
||||||
|
// Which digital row the matrix opens on. Empty = DIG, the group of them all.
|
||||||
|
// Set in Settings ▸ General; see the rotation below.
|
||||||
|
export const MATRIX_DIGI_KEY = 'opslog.matrixDigiMode';
|
||||||
function classMatchesMode(cls: string, mode: string): boolean {
|
function classMatchesMode(cls: string, mode: string): boolean {
|
||||||
const u = (mode || '').toUpperCase();
|
const u = (mode || '').toUpperCase();
|
||||||
if (cls === 'PH') return PHONE_MODES.has(u);
|
if (cls === 'PH') return PHONE_MODES.has(u);
|
||||||
@@ -75,30 +84,85 @@ const STATUS_CLASSES: Record<string, string> = {
|
|||||||
dxcc_w: 'bg-mx-dx-work',
|
dxcc_w: 'bg-mx-dx-work',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Legend entries, in the same colour order as the cells. swatch = the
|
// Legend entries, in the same colour order as the cells — and the same order and
|
||||||
// background class (or a special ring marker for the current-entry cell).
|
// i18n keys the Appearance panel's colour pickers use, so the two can never
|
||||||
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
// disagree about which green is which. swatch = the background class (or a
|
||||||
{ swatch: 'bg-mx-call-conf', label: 'Call confirmed' },
|
// special ring marker for the current-entry cell).
|
||||||
{ swatch: 'bg-mx-call-work', label: 'Call worked' },
|
const LEGEND: { swatch: string; ring?: boolean; mark?: string; label: string }[] = [
|
||||||
{ swatch: 'bg-mx-dx-conf', label: 'Entity confirmed' },
|
{ swatch: 'bg-mx-call-conf', label: 'mx.callConf' },
|
||||||
{ swatch: 'bg-mx-dx-work', label: 'Entity worked' },
|
{ swatch: 'bg-mx-call-work', label: 'mx.callWork' },
|
||||||
{ swatch: 'bg-mx-none', label: 'Not worked' },
|
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
|
||||||
{ swatch: 'bg-mx-none', ring: true, label: 'Current entry' },
|
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
|
||||||
|
{ swatch: 'bg-mx-none', label: 'mx.none' },
|
||||||
|
{ swatch: 'bg-mx-none', ring: true, label: 'mx.current' },
|
||||||
|
{ swatch: 'bg-mx-none', mark: 'w', label: 'mx.markWork' },
|
||||||
|
{ swatch: 'bg-mx-none', mark: 'c', label: 'mx.markConf' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
|
// CallMark — "this callsign has already been worked on this slot".
|
||||||
const desc =
|
//
|
||||||
status === 'call_c' ? 'This callsign confirmed' :
|
// Drawn the same way on every cell, whatever colour the entity status gave it:
|
||||||
status === 'call_w' ? 'This callsign worked (not confirmed)' :
|
// the operator learns one shape and reads it without first working out what the
|
||||||
status === 'dxcc_c' ? 'Entity confirmed (other callsign)' :
|
// background means. Only the fill changes, and only with the callsign's own
|
||||||
status === 'dxcc_w' ? 'Entity worked (other callsign)' :
|
// state (worked / confirmed) — never with the entity's. The ring is the theme
|
||||||
'Never worked';
|
// background, which is what keeps the dot legible over all five cell colours.
|
||||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
function CallMark({ state = 'w' }: { state?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute top-[4px] right-[4px] size-[5px] rounded-full ring-1 ring-background',
|
||||||
|
state === 'c' ? 'bg-mx-mark-conf' : 'bg-mx-mark-work',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean, call = ''): string {
|
||||||
|
const desc =
|
||||||
|
status === 'call_c' ? t('mx.tipCallConf') :
|
||||||
|
status === 'call_w' ? t('mx.tipCallWork') :
|
||||||
|
status === 'dxcc_c' ? t('mx.tipDxConf') :
|
||||||
|
status === 'dxcc_w' ? t('mx.tipDxWork') :
|
||||||
|
t('mx.tipNone');
|
||||||
|
const mine = call === 'c' ? t('mx.tipThisCallConf') : call === 'w' ? t('mx.tipThisCall') : '';
|
||||||
|
return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, modes, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
// Cell drill-down: which band+class the operator clicked, or null.
|
// Cell drill-down: which band+class the operator clicked, or null.
|
||||||
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
||||||
|
|
||||||
|
// The DIGITAL row is a rotation, not a fixed row.
|
||||||
|
//
|
||||||
|
// One row for every digital mode would be the honest layout and there is no
|
||||||
|
// height for it — the matrix sits in a fixed panel beside a dozen widgets.
|
||||||
|
// So the row keeps its place and changes what it answers: DIG (all of them),
|
||||||
|
// then each digital mode the operator actually uses, in the order their mode
|
||||||
|
// list gives, then back to DIG. The backend publishes the same cells under
|
||||||
|
// both the class name and the raw mode, so a rotation costs no round trip.
|
||||||
|
const digModes = useMemo(
|
||||||
|
() => (modes ?? [])
|
||||||
|
.map((m) => (m || '').toUpperCase().trim())
|
||||||
|
.filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)),
|
||||||
|
[modes],
|
||||||
|
);
|
||||||
|
// Where the rotation STARTS. An operator who only ever works FT8 was shown
|
||||||
|
// "DIG" every time and had to click to the mode they actually use, on every
|
||||||
|
// callsign — so the row they want is the one it opens on. Empty (the default)
|
||||||
|
// keeps DIG, which is right for anyone working several digital modes.
|
||||||
|
const [digIdx, setDigIdx] = useState(0); // 0 = the DIG group itself
|
||||||
|
useEffect(() => {
|
||||||
|
const want = (localStorage.getItem(MATRIX_DIGI_KEY) || '').toUpperCase().trim();
|
||||||
|
if (!want) { setDigIdx(0); return; }
|
||||||
|
const i = digModes.indexOf(want);
|
||||||
|
setDigIdx(i >= 0 ? i + 1 : 0);
|
||||||
|
}, [digModes]);
|
||||||
|
// A shorter mode list (the operator edited it) must not strand the rotation
|
||||||
|
// on a row that no longer exists.
|
||||||
|
const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0;
|
||||||
|
const digRow = digPos === 0 ? 'DIG' : digModes[digPos - 1];
|
||||||
|
const cycleDig = () => setDigIdx((i) => (digModes.length ? (i + 1) % (digModes.length + 1) : 0));
|
||||||
// Columns from the operator's configured bands (so the matrix shows only the
|
// Columns from the operator's configured bands (so the matrix shows only the
|
||||||
// bands they actually use), falling back to the built-in default set.
|
// bands they actually use), falling back to the built-in default set.
|
||||||
const cols = useMemo(
|
const cols = useMemo(
|
||||||
@@ -121,6 +185,16 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
return m;
|
return m;
|
||||||
}, [wb]);
|
}, [wb]);
|
||||||
|
|
||||||
|
// Worked-with-this-callsign, per cell — carried separately by the backend
|
||||||
|
// because collapsing it into the status is what hid it.
|
||||||
|
const callMap = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const s of wb?.band_status ?? []) {
|
||||||
|
if ((s as any).call) m.set(`${s.band}|${s.class}`, (s as any).call);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [wb]);
|
||||||
|
|
||||||
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
||||||
// Derived straight from the entity's real band_status (all bands it was
|
// Derived straight from the entity's real band_status (all bands it was
|
||||||
// worked on — not just the operator's configured column list).
|
// worked on — not just the operator's configured column list).
|
||||||
@@ -172,8 +246,17 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
|
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
|
||||||
[lat, lon],
|
[lat, lon],
|
||||||
);
|
);
|
||||||
|
// Stacked, not side by side. Laid out in a row this cost about 150 px of a
|
||||||
|
// header that has to hold the callsign, the badges and the band grid, and it
|
||||||
|
// was what pushed the whole row onto a second line. Two short times one above
|
||||||
|
// the other take a fraction of that and no extra height: the row is already
|
||||||
|
// taller than one line of text.
|
||||||
|
//
|
||||||
|
// "UTC" moves into the tooltip with them — the times are monospaced and always
|
||||||
|
// UTC everywhere in OpsLog, so the label was spending width to repeat a
|
||||||
|
// convention the operator already lives by.
|
||||||
const sunBlock = sun ? (
|
const sunBlock = sun ? (
|
||||||
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
|
<div className="ml-auto flex flex-col items-end leading-tight text-xs shrink-0"
|
||||||
title="Sunrise / sunset at the DX station (UTC)">
|
title="Sunrise / sunset at the DX station (UTC)">
|
||||||
{sun.polarDay ? (
|
{sun.polarDay ? (
|
||||||
<span className="font-semibold text-warning">midnight sun</span>
|
<span className="font-semibold text-warning">midnight sun</span>
|
||||||
@@ -182,14 +265,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Sunrise className="size-3.5 text-warning" />
|
<Sunrise className="size-3 text-warning" />
|
||||||
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Sunset className="size-3.5 text-info" />
|
<Sunset className="size-3 text-info" />
|
||||||
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">UTC</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -198,11 +280,11 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
|
'flex items-center gap-2 px-2 py-2 flex-wrap shrink-0',
|
||||||
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-[220px] flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
{newOne ? (
|
{newOne ? (
|
||||||
<>
|
<>
|
||||||
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
||||||
@@ -225,7 +307,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
{forCall}
|
{forCall}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
<Badge className="bg-primary text-primary-foreground px-2 py-0.5 text-xs normal-case font-semibold tracking-normal leading-tight">
|
||||||
{dxccName || `DXCC #${dxcc}`}
|
{dxccName || `DXCC #${dxcc}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
{mwBadge}
|
{mwBadge}
|
||||||
@@ -266,7 +348,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
<table className="border-separate" style={{ borderSpacing: 3 }}>
|
<table className="border-separate" style={{ borderSpacing: 3 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-[26px]" />
|
<th className="w-[38px] min-w-[38px] max-w-[38px]" />
|
||||||
{cols.map((b) => (
|
{cols.map((b) => (
|
||||||
<th
|
<th
|
||||||
key={b.tag}
|
key={b.tag}
|
||||||
@@ -281,13 +363,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{CLASSES.map((cls) => {
|
{CLASSES.map((clsBase) => {
|
||||||
const classCurrent = classMatchesMode(cls, currentMode);
|
const cls = clsBase === 'DIG' ? digRow : clsBase;
|
||||||
|
// On a specific digital mode the "you are here" mark has to be that
|
||||||
|
// mode, not any digital one — otherwise every FT4 entry lights the
|
||||||
|
// FT8 row it happens to be cycled to.
|
||||||
|
const classCurrent = cls === clsBase
|
||||||
|
? classMatchesMode(cls, currentMode)
|
||||||
|
: (currentMode || '').toUpperCase() === cls;
|
||||||
return (
|
return (
|
||||||
<tr key={cls}>
|
<tr key={cls}>
|
||||||
<th
|
<th
|
||||||
|
onClick={clsBase === 'DIG' && digModes.length ? cycleDig : undefined}
|
||||||
|
title={clsBase === 'DIG' && digModes.length ? t('bsg.digCycle') : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-[11px] font-semibold pr-1.5 text-right w-[26px]',
|
// Sized once for the LONGEST label the rotation can show,
|
||||||
|
// and pinned there: a column that grows when RTTY comes
|
||||||
|
// round shifts every band beneath it, and the eye reads
|
||||||
|
// that as the matrix moving rather than the row changing.
|
||||||
|
'font-mono font-semibold pr-1.5 text-right w-[38px] min-w-[38px] max-w-[38px] overflow-hidden',
|
||||||
|
// Beyond four characters (PSK31, MSK144) the type gives way
|
||||||
|
// instead of the column.
|
||||||
|
cls.length > 4 ? 'text-[9px]' : 'text-[11px]',
|
||||||
|
clsBase === 'DIG' && digModes.length ? 'cursor-pointer hover:text-foreground' : '',
|
||||||
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -295,21 +393,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
</th>
|
</th>
|
||||||
{cols.map((b) => {
|
{cols.map((b) => {
|
||||||
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
||||||
|
// The same cell's other answer: worked with THIS callsign
|
||||||
|
// here. The status above is the entity's, and a confirmed
|
||||||
|
// entity outranks a worked call — so chasing a DXpedition,
|
||||||
|
// the cell could say "confirmed" about a contact made years
|
||||||
|
// ago and nothing about the one made this morning.
|
||||||
|
const mine = callMap.get(`${b.tag}|${cls}`) ?? '';
|
||||||
const isCurrent = hasCall && b.tag === currentBand && classCurrent;
|
const isCurrent = hasCall && b.tag === currentBand && classCurrent;
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={b.tag}
|
key={b.tag}
|
||||||
title={cellTitle(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
|
title={cellTitle(t, b.tag, cls, st, isCurrent, mine) + (st ? ' — ' + t('mx.tipClick') : '')}
|
||||||
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
|
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[28px] h-[24px] rounded transition-colors p-0',
|
'relative w-[28px] h-[24px] rounded transition-colors p-0',
|
||||||
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
||||||
// Only a filled cell has anything to show — an empty one
|
// Only a filled cell has anything to show — an empty one
|
||||||
// stays inert rather than opening a "no QSOs" dialog.
|
// stays inert rather than opening a "no QSOs" dialog.
|
||||||
st && 'cursor-pointer hover:brightness-110',
|
st && 'cursor-pointer hover:brightness-110',
|
||||||
isCurrent && 'ring-2 ring-warning ring-inset',
|
isCurrent && 'ring-2 ring-mx-cur ring-inset',
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
|
{mine ? <CallMark state={mine} /> : null}
|
||||||
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -324,12 +430,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
<span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
<span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-block size-3 rounded shrink-0',
|
'relative inline-block size-3 rounded shrink-0',
|
||||||
l.swatch,
|
l.swatch,
|
||||||
l.ring && 'ring-2 ring-warning ring-inset',
|
l.ring && 'ring-2 ring-mx-cur ring-inset',
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
{l.label}
|
{l.mark ? <CallMark state={l.mark} /> : null}
|
||||||
|
</span>
|
||||||
|
{t(l.label)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -418,7 +526,7 @@ function SlotQSOModal({ call, dxcc, entity, band, cls, onClose, onEdit }: {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((q, i) => {
|
{rows.map((q, i) => {
|
||||||
const cfm = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y';
|
const cfm = isQSLConfirmed(q.lotw_rcvd) || isQSLConfirmed(q.eqsl_rcvd) || isQSLConfirmed(q.qsl_rcvd);
|
||||||
const mine = q.callsign === call;
|
const mine = q.callsign === call;
|
||||||
return (
|
return (
|
||||||
<tr key={q.id ?? i} className="border-t border-border/40 even:bg-muted/[0.06] hover:bg-primary/[0.06] transition-colors">
|
<tr key={q.id ?? i} className="border-t border-border/40 even:bg-muted/[0.06] hover:bg-primary/[0.06] transition-colors">
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
||||||
|
{ id: 'qsl_sent_via', label: 'bulk.fQslSentVia', group: 'QSL / upload', kind: 'text' },
|
||||||
|
{ id: 'qsl_rcvd_via', label: 'bulk.fQslRcvdVia', group: 'QSL / upload', kind: 'text' },
|
||||||
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
@@ -44,14 +46,27 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'clublog_rcvd', label: 'bulk.fClublogRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'clublog_rcvd_date', label: 'bulk.fClublogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
// HAMLOG.online: no promoted column, written into extras_json (see
|
||||||
|
// qso.bulkEditableExtras). Status and date, like every other service here.
|
||||||
|
{ id: 'hamlog_sent', label: 'bulk.fHamlogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'hamqth_sent', label: 'bulk.fHamqthSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'hamqth_sent_date', label: 'bulk.fHamqthSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
// My station / operator
|
// My station / operator
|
||||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||||
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
|
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
|
||||||
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true },
|
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_dxcc', label: 'bulk.fMyDxcc', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_cq_zone', label: 'bulk.fMyCqZone', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_itu_zone', label: 'bulk.fMyItuZone', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
|
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
|
||||||
@@ -91,6 +106,17 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
|
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
||||||
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
||||||
|
// The contact itself — repair fields, not description fields. An import that
|
||||||
|
// mapped every QSO to SSB, or an ADIF with no MODE at all, is fixed here
|
||||||
|
// instead of one row at a time.
|
||||||
|
//
|
||||||
|
// Band is deliberately absent: it travels with the frequency below, because a
|
||||||
|
// band contradicting its own frequency is invalid ADIF and every export would
|
||||||
|
// carry the contradiction.
|
||||||
|
{ id: 'mode', label: 'bulk.fMode', group: 'The contact', kind: 'text', upper: true },
|
||||||
|
{ id: 'submode', label: 'bulk.fSubmode', group: 'The contact', kind: 'text', upper: true },
|
||||||
|
{ id: 'rst_sent', label: 'bulk.fRstSent', group: 'The contact', kind: 'text' },
|
||||||
|
{ id: 'rst_rcvd', label: 'bulk.fRstRcvd', group: 'The contact', kind: 'text' },
|
||||||
// Misc
|
// Misc
|
||||||
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
|
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
|
||||||
// logged on a stale/default frequency after CAT dropped.
|
// logged on a stale/default frequency after CAT dropped.
|
||||||
@@ -110,7 +136,12 @@ const STATUS_VALUES: { v: string; label: string }[] = [
|
|||||||
{ v: '_', label: 'bulk.statusBlank' },
|
{ v: '_', label: 'bulk.statusBlank' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const GROUPS = ['QSL / upload', 'My station', 'Contacted station', 'Contest', 'Propagation', 'Misc'];
|
// Derived from the fields themselves, in the order they are declared.
|
||||||
|
//
|
||||||
|
// This used to be a hand-written list, and a group added to FIELDS but not to it
|
||||||
|
// simply never rendered — the fields existed, passed every check, and could not
|
||||||
|
// be picked. Two lists that must agree, with nothing to make them.
|
||||||
|
const GROUPS = [...new Set(FIELDS.map((f) => f.group))];
|
||||||
// Maps the internal group key → its i18n label key.
|
// Maps the internal group key → its i18n label key.
|
||||||
const GROUP_LABELS: Record<string, string> = {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
'QSL / upload': 'bulk.groupQsl',
|
'QSL / upload': 'bulk.groupQsl',
|
||||||
@@ -119,6 +150,7 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
'Contest': 'bulk.groupContest',
|
'Contest': 'bulk.groupContest',
|
||||||
'Propagation': 'bulk.groupPropagation',
|
'Propagation': 'bulk.groupPropagation',
|
||||||
'Misc': 'bulk.groupMisc',
|
'Misc': 'bulk.groupMisc',
|
||||||
|
'The contact': 'bulk.groupContact',
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -161,7 +193,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent overlayBlur={false} className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('bulk.title')}</DialogTitle>
|
<DialogTitle>{t('bulk.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -177,7 +209,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{GROUPS.map((g) => (
|
{GROUPS.map((g) => (
|
||||||
<div key={g}>
|
<div key={g}>
|
||||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{GROUP_LABELS[g] ? t(GROUP_LABELS[g]) : g}</div>
|
||||||
{FIELDS.filter((f) => f.group === g)
|
{FIELDS.filter((f) => f.group === g)
|
||||||
.map((f) => ({ f, txt: t(f.label) }))
|
.map((f) => ({ f, txt: t(f.label) }))
|
||||||
.sort((a, b) => a.txt.localeCompare(b.txt))
|
.sort((a, b) => a.txt.localeCompare(b.txt))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Star } from 'lucide-react';
|
import { Star } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { isQSLConfirmed } from '@/lib/qsl';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
type WorkedBefore = WorkedBeforeView;
|
type WorkedBefore = WorkedBeforeView;
|
||||||
@@ -97,10 +98,10 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
|||||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
|
||||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
||||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
||||||
{e.lotw_rcvd === 'Y' && (
|
{isQSLConfirmed(e.lotw_rcvd) && (
|
||||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-info-foreground bg-info mr-0.5" title={t('chp.lotwRcvd')}>L</span>
|
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-info-foreground bg-info mr-0.5" title={t('chp.lotwRcvd')}>L</span>
|
||||||
)}
|
)}
|
||||||
{e.qsl_rcvd === 'Y' && (
|
{isQSLConfirmed(e.qsl_rcvd) && (
|
||||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-success-foreground bg-success mr-0.5" title={t('chp.bureauRcvd')}>B</span>
|
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-success-foreground bg-success mr-0.5" title={t('chp.bureauRcvd')}>B</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// ChaseNewPanel — the stations PSK Reporter is hearing NEAR HERE that are new
|
||||||
|
// against the log.
|
||||||
|
//
|
||||||
|
// A DX cluster tells you what somebody chose to spot. This tells you what is
|
||||||
|
// actually being decoded in your own region, which is a different and often
|
||||||
|
// larger set: nobody spots the FT8 caller running ten watts from a rare square.
|
||||||
|
//
|
||||||
|
// The one thing an operator has to know, and the reason for the line at the
|
||||||
|
// bottom: PSK Reporter carries DIGITAL MODES ONLY. An empty panel means nothing
|
||||||
|
// new is being decoded on FT8/FT4/JS8 near here — not that the band is dead.
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Radar, Loader2, X } from 'lucide-react';
|
||||||
|
import { formatDistance } from '@/lib/units';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { chaseAllows } from '@/lib/spotDisplay';
|
||||||
|
import { markerColour } from '@/lib/spotMarkers';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { GetChaseNewSpots, GetPSKReporterStatus } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
export interface ChaseNewSpot {
|
||||||
|
call: string;
|
||||||
|
band: string;
|
||||||
|
mode: string;
|
||||||
|
freq_hz?: number;
|
||||||
|
grid?: string;
|
||||||
|
country?: string;
|
||||||
|
cont?: string;
|
||||||
|
dist_km?: number;
|
||||||
|
bearing?: number;
|
||||||
|
status?: string;
|
||||||
|
new_pfx?: boolean;
|
||||||
|
new_grid?: boolean;
|
||||||
|
lotw?: boolean;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Tuning the rig to a row is the whole point — a station heard on 14.074 is
|
||||||
|
// only useful if you can get there in one click.
|
||||||
|
onPick?: (s: ChaseNewSpot) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The categories, in priority order. A station can be several at once — a new
|
||||||
|
// band AND a new prefix — but a row shows ONE, the first that matches here.
|
||||||
|
//
|
||||||
|
// The order is by what would make an operator leave what they are doing: a new
|
||||||
|
// entity beats a new band on it, which beats a new mode, which beats a new slot;
|
||||||
|
// a prefix or a square is worth knowing but never worth interrupting a QSO for.
|
||||||
|
// Two badges on one line also made the list unreadable at a glance, which is the
|
||||||
|
// only thing this panel is for.
|
||||||
|
type Category = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid';
|
||||||
|
|
||||||
|
const CATEGORIES: Array<{ key: Category; labelKey: string; colour: string }> = [
|
||||||
|
{ key: 'dxcc', labelKey: 'clg2.newDxcc', colour: 'var(--danger)' },
|
||||||
|
{ key: 'band', labelKey: 'clg2.newBand', colour: 'var(--danger)' },
|
||||||
|
{ key: 'mode', labelKey: 'clg2.newMode', colour: 'var(--danger)' },
|
||||||
|
{ key: 'slot', labelKey: 'clg2.newSlot', colour: 'var(--danger)' },
|
||||||
|
{ key: 'pfx', labelKey: 'clg2.newPfx', colour: markerColour('new_pfx') },
|
||||||
|
{ key: 'grid', labelKey: 'clg2.newGrid', colour: markerColour('new_grid') },
|
||||||
|
];
|
||||||
|
|
||||||
|
// categoryOf is the single thing a row says about a station.
|
||||||
|
//
|
||||||
|
// A category the operator does not chase is not a category here either: with
|
||||||
|
// prefixes and squares switched off this panel showed rows whose only reason
|
||||||
|
// for being listed had been withdrawn everywhere else.
|
||||||
|
function categoryOf(s: ChaseNewSpot): Category | null {
|
||||||
|
switch (s.status) {
|
||||||
|
case 'new': return 'dxcc';
|
||||||
|
case 'new-band-mode': return 'band';
|
||||||
|
case 'new-band': return 'band';
|
||||||
|
case 'new-mode': return 'mode';
|
||||||
|
case 'new-slot': return 'slot';
|
||||||
|
}
|
||||||
|
if (s.new_pfx && chaseAllows('pfx')) return 'pfx';
|
||||||
|
if (s.new_grid && chaseAllows('grid')) return 'grid';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_KEY = 'opslog.chaseNewFilters';
|
||||||
|
|
||||||
|
function allowedCategories(): Category[] {
|
||||||
|
return CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFilters(): Set<Category> {
|
||||||
|
const allowed = allowedCategories();
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(FILTER_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const list = JSON.parse(raw) as Category[];
|
||||||
|
// A stored set holding NONE of the categories on offer hides the whole
|
||||||
|
// panel, for ever, with nothing to say why — and that is exactly what a
|
||||||
|
// preference written by an older build does once a category is renamed.
|
||||||
|
// Treated as "no preference": a panel that shows nothing at every launch
|
||||||
|
// is never what was meant, and the chips are one click away.
|
||||||
|
if (Array.isArray(list) && list.some((k) => allowed.includes(k))) return new Set(list);
|
||||||
|
}
|
||||||
|
} catch { /* a corrupt preference is not worth a broken panel */ }
|
||||||
|
return new Set(allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [on, setOn] = useState<Set<Category>>(loadFilters);
|
||||||
|
// The FEED, not the list: connected or not, how many reports it has taken,
|
||||||
|
// and what it is filtered on. Without it an empty panel says nothing about
|
||||||
|
// whether anything is arriving at all — which is the first question.
|
||||||
|
const [feed, setFeed] = useState<any>(null);
|
||||||
|
|
||||||
|
// Polled rather than pushed: the feed can deliver several a second under an
|
||||||
|
// opening, and an event per row would be a redraw per row for a list nobody
|
||||||
|
// reads that fast.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
|
||||||
|
if (alive) { setSpots(r); setLoaded(true); }
|
||||||
|
const st = await GetPSKReporterStatus();
|
||||||
|
if (alive) setFeed(st);
|
||||||
|
} catch { /* the feed may not be up yet */ }
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 5000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggle(k: Category) {
|
||||||
|
setOn((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(k)) next.delete(k); else next.add(k);
|
||||||
|
try { localStorage.setItem(FILTER_KEY, JSON.stringify([...next])); } catch { /* not worth failing over */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
return spots.filter((s) => {
|
||||||
|
const c = categoryOf(s);
|
||||||
|
return c !== null && on.has(c);
|
||||||
|
});
|
||||||
|
}, [spots, on]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col rounded-md border border-border bg-card overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-2 py-1.5">
|
||||||
|
<Radar className="size-3.5 shrink-0 text-primary" />
|
||||||
|
<span className="shrink-0 text-xs font-semibold">{t('chn.title')}</span>
|
||||||
|
|
||||||
|
{/* Filters, in the same order and colours as the badges they hide. */}
|
||||||
|
<div className="flex flex-1 flex-wrap items-center gap-1">
|
||||||
|
{CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(c.key)}
|
||||||
|
title={t('chn.filterHint')}
|
||||||
|
className={cn(
|
||||||
|
'rounded border px-1 py-px text-[9px] font-bold uppercase tracking-wide transition-colors',
|
||||||
|
on.has(c.key) ? 'border-transparent' : 'border-border text-muted-foreground opacity-50',
|
||||||
|
)}
|
||||||
|
style={on.has(c.key) ? { color: c.colour, borderColor: c.colour } : undefined}
|
||||||
|
>
|
||||||
|
{t(c.labelKey).replace(/^(NEW|NOUV)\s+/i, '')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Both numbers: what is on screen, and what was heard. They differ
|
||||||
|
exactly when a category is switched off, which is the one case an
|
||||||
|
operator reads this panel as broken. */}
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
|
{loaded ? (shown.length === spots.length
|
||||||
|
? t('chn.count', { n: spots.length })
|
||||||
|
: t('chn.countOf', { n: shown.length, total: spots.length })) : ''}
|
||||||
|
</span>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
title={t('chn.close')}
|
||||||
|
className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{!loaded ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 p-3 text-[11px] text-muted-foreground">
|
||||||
|
<Loader2 className="size-3 animate-spin" /> {t('chn.loading')}
|
||||||
|
</div>
|
||||||
|
) : shown.length === 0 ? (
|
||||||
|
<p className="p-3 text-[11px] text-muted-foreground leading-relaxed">
|
||||||
|
{spots.length === 0 ? t('chn.empty') : t('chn.allFiltered')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{shown.map((s, i) => {
|
||||||
|
const c = categoryOf(s);
|
||||||
|
const def = CATEGORIES.find((x) => x.key === c);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${s.call}-${s.band}-${s.mode}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick?.(s)}
|
||||||
|
className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] hover:bg-accent/40"
|
||||||
|
title={[
|
||||||
|
s.country,
|
||||||
|
s.grid,
|
||||||
|
s.dist_km ? formatDistance(s.dist_km) : '',
|
||||||
|
s.freq_hz ? `${(s.freq_hz / 1000).toFixed(1)} kHz` : '',
|
||||||
|
].filter(Boolean).join(' · ')}
|
||||||
|
>
|
||||||
|
<span className="w-[84px] shrink-0 truncate font-mono font-bold">{s.call}</span>
|
||||||
|
<span className="w-9 shrink-0 font-mono text-muted-foreground">{s.band}</span>
|
||||||
|
<span className="w-10 shrink-0 truncate text-muted-foreground">{s.mode}</span>
|
||||||
|
{/* No frequency column: clicking the row tunes the rig to it,
|
||||||
|
so the number was a thing to read and never to use. It is
|
||||||
|
still carried on the spot — the click is what needs it —
|
||||||
|
and shows in the row's tooltip for anyone who wants it.
|
||||||
|
The country takes the space it freed. */}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">{s.country ?? ''}</span>
|
||||||
|
{def && (
|
||||||
|
<span className="shrink-0 text-[9px] font-bold" style={{ color: def.colour }}>
|
||||||
|
{t(def.labelKey)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.lotw && <span className="shrink-0 text-[9px] text-info" title="LoTW">L</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The radius comes from the FEED, not from a sentence: it was written
|
||||||
|
into this line as "~300 km" and stayed 300 while the setting said
|
||||||
|
1000, which is the panel telling the operator their change did not
|
||||||
|
take when it had. */}
|
||||||
|
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">
|
||||||
|
{t('chn.heardWithin', { km: feed?.near_km || 300 })}
|
||||||
|
{feed && (feed.running
|
||||||
|
? <span className="text-success"> · {t('chn.feedOn', { n: feed.received ?? 0, sq: feed.squares ?? 0 })}</span>
|
||||||
|
: <span className="text-warning"> · {feed.last_err ? t('chn.feedErr', { e: feed.last_err }) : t('chn.feedOff')}</span>)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry,
|
AllCommunityModule, ModuleRegistry,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, type RowDoubleClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
@@ -13,8 +13,9 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||||
import { markerColour } from '@/lib/spotMarkers';
|
import { markerColour } from '@/lib/spotMarkers';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
import { applySpotDisplay, chasePota, chaseSota, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
|
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
@@ -45,6 +46,7 @@ export type ClusterSpot = {
|
|||||||
raw: string;
|
raw: string;
|
||||||
repeats?: number;
|
repeats?: number;
|
||||||
pota_ref?: string;
|
pota_ref?: string;
|
||||||
|
sota_ref?: string;
|
||||||
pota_name?: string;
|
pota_name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,18 +65,33 @@ export type SpotStatusEntry = {
|
|||||||
state?: string;
|
state?: string;
|
||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
new_pfx?: boolean;
|
new_pfx?: boolean;
|
||||||
|
unconf_status?: boolean;
|
||||||
|
unconf_pfx?: boolean;
|
||||||
|
unconf_cty?: boolean;
|
||||||
pfx?: string;
|
pfx?: string;
|
||||||
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
|
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
|
||||||
lotw?: boolean;
|
lotw?: boolean;
|
||||||
spotter_continent?: string;
|
spotter_continent?: string;
|
||||||
grid?: string;
|
grid?: string;
|
||||||
new_grid?: boolean;
|
new_grid?: boolean;
|
||||||
|
grid_state?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rows: ClusterSpot[];
|
rows: ClusterSpot[];
|
||||||
spotStatus: Record<string, SpotStatusEntry>;
|
spotStatus: Record<string, SpotStatusEntry>;
|
||||||
|
// A DOUBLE click works the spot: QSY, mode, callsign, the lot.
|
||||||
onSpotClick?: (s: ClusterSpot) => void;
|
onSpotClick?: (s: ClusterSpot) => void;
|
||||||
|
// A SINGLE click only fills the callsign. Reading the cluster means passing
|
||||||
|
// over dozens of lines, and every pass used to drag the radio with it — one
|
||||||
|
// stray click while looking took the operator off the station they were
|
||||||
|
// working. Looking and going are now two different gestures.
|
||||||
|
onSpotSelect?: (s: ClusterSpot) => void;
|
||||||
|
// Anything the caller wants on the LEFT of the toolbar — the pane's title, its
|
||||||
|
// live count, its Filters button. Docked in a pane, the title used to sit on a
|
||||||
|
// row of its own above this one, so the cluster ate two lines of a short pane
|
||||||
|
// where Recent QSOs beside it ate one. Reported by VK4DX.
|
||||||
|
headerLeft?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.clusterColState.v1';
|
const COL_STATE_KEY = 'hamlog.clusterColState.v1';
|
||||||
@@ -134,6 +151,12 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
|||||||
// filled pfx → new prefix filled POTA → new park filled county → new county
|
// filled pfx → new prefix filled POTA → new park filled county → new county
|
||||||
// blue call → already worked (not a novelty, so text only)
|
// blue call → already worked (not a novelty, so text only)
|
||||||
const NEW = 'var(--warning)'; // yellow: something here is new
|
const NEW = 'var(--warning)'; // yellow: something here is new
|
||||||
|
// NEW SLOT gets its own hue. It shared the amber NEW family while the NEW PFX
|
||||||
|
// marker sits in caution yellow — two different facts, two near-identical
|
||||||
|
// colours in the same cell. Sky cyan, the exact colour the panadapter palette
|
||||||
|
// ships for new-slot (#5AC8FA), so the two views tell one story — and clearly
|
||||||
|
// apart from the POTA green the first attempt (aqua) sat next to.
|
||||||
|
const NEWSLOT = '#5AC8FA';
|
||||||
const WKD = 'var(--info)'; // blue: this callsign is already in the log
|
const WKD = 'var(--info)'; // blue: this callsign is already in the log
|
||||||
|
|
||||||
// FILLING the cell that carries the fact, rather than only tinting its text.
|
// FILLING the cell that carries the fact, rather than only tinting its text.
|
||||||
@@ -173,11 +196,13 @@ function cellText(value: any, color: string | null): any {
|
|||||||
function statusColor(s: SpotStatusEntry | undefined): string | null {
|
function statusColor(s: SpotStatusEntry | undefined): string | null {
|
||||||
switch (s?.status) {
|
switch (s?.status) {
|
||||||
case 'new':
|
case 'new':
|
||||||
|
case 'new-band-mode':
|
||||||
case 'new-band':
|
case 'new-band':
|
||||||
case 'new-mode':
|
case 'new-mode':
|
||||||
case 'new-slot':
|
|
||||||
case 'new-call':
|
case 'new-call':
|
||||||
return NEW;
|
return NEW;
|
||||||
|
case 'new-slot':
|
||||||
|
return NEWSLOT;
|
||||||
default:
|
default:
|
||||||
return s?.worked_call ? WKD : null;
|
return s?.worked_call ? WKD : null;
|
||||||
}
|
}
|
||||||
@@ -192,7 +217,7 @@ function statusColor(s: SpotStatusEntry | undefined): string | null {
|
|||||||
// (still loading, or entity unknown) are NOT dimmed — that would flicker.
|
// (still loading, or entity unknown) are NOT dimmed — that would flicker.
|
||||||
function isDull(s: SpotStatusEntry | undefined): boolean {
|
function isDull(s: SpotStatusEntry | undefined): boolean {
|
||||||
if (!s || !s.status) return false;
|
if (!s || !s.status) return false;
|
||||||
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot' || s.status === 'new-call') return false;
|
if (s.status === 'new' || s.status === 'new-band-mode' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot' || s.status === 'new-call') return false;
|
||||||
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx || s.new_grid);
|
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx || s.new_grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +283,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
|
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
|
||||||
|
else if (s?.status === 'new-band-mode') parts.push(t('clg2.newBandMode'));
|
||||||
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
|
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
|
||||||
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
|
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
|
||||||
else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
|
else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
|
||||||
@@ -266,33 +292,43 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
||||||
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
||||||
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
|
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
|
||||||
if (s?.new_grid) parts.push(t('clg2.newGrid'));
|
if (s?.new_grid) parts.push(s.grid_state === 'unconf' ? t('clg2.newGridUnconf') : t('clg2.newGrid'));
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
},
|
},
|
||||||
cellRenderer: (p: any) => {
|
cellRenderer: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
const parts: { text: string; color: string }[] = [];
|
const parts: { text: string; color: string; dim?: boolean }[] = [];
|
||||||
const main = statusColor(s);
|
const main = statusColor(s);
|
||||||
if (main) {
|
if (main) {
|
||||||
const label = s?.status === 'new' ? t('clg2.newDxcc')
|
const label = s?.status === 'new' ? t('clg2.newDxcc')
|
||||||
|
: s?.status === 'new-band-mode' ? t('clg2.newBandMode')
|
||||||
: s?.status === 'new-band' ? t('clg2.newBand')
|
: s?.status === 'new-band' ? t('clg2.newBand')
|
||||||
: s?.status === 'new-mode' ? t('clg2.newMode')
|
: s?.status === 'new-mode' ? t('clg2.newMode')
|
||||||
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
||||||
: s?.status === 'new-call' ? t('clg2.newCall')
|
: s?.status === 'new-call' ? t('clg2.newCall')
|
||||||
: t('clg2.wkdCall');
|
: t('clg2.wkdCall');
|
||||||
parts.push({ text: label, color: main });
|
// Dimmed when the need is only a missing confirmation — the grid's
|
||||||
|
// own convention, now spoken by every category.
|
||||||
|
parts.push(s?.unconf_status ? { text: label, color: main, dim: true } : { text: label, color: main });
|
||||||
}
|
}
|
||||||
// Colours from lib/spotMarkers — shared with the band map so a marker is
|
// Colours from lib/spotMarkers — shared with the band map so a marker is
|
||||||
// never one colour here and another there.
|
// never one colour here and another there.
|
||||||
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') });
|
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county'), dim: !!s?.unconf_cty });
|
||||||
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
|
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
|
||||||
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') });
|
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx'), dim: !!s?.unconf_pfx });
|
||||||
if (s?.new_grid) parts.push({ text: t('clg2.newGrid'), color: markerColour('new_grid') });
|
// Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
|
||||||
|
// held back, so it reads as "less" of the same thing rather than a
|
||||||
|
// different fact — and the label says which.
|
||||||
|
if (s?.new_grid) {
|
||||||
|
parts.push(s.grid_state === 'unconf'
|
||||||
|
? { text: t('clg2.newGridUnconf'), color: markerColour('new_grid'), dim: true }
|
||||||
|
: { text: t('clg2.newGrid'), color: markerColour('new_grid') });
|
||||||
|
}
|
||||||
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||||
return (
|
return (
|
||||||
<span style={{ whiteSpace: 'nowrap' }}>
|
<span style={{ whiteSpace: 'nowrap' }}>
|
||||||
{parts.map((pt, i) => (
|
{parts.map((pt, i) => (
|
||||||
<span key={i} style={{ color: pt.color, fontWeight: 700 }}>
|
<span key={i} style={{ color: pt.color, fontWeight: 700, opacity: pt.dim ? 0.5 : 1 }}>
|
||||||
{i > 0 ? ' · ' : ''}{pt.text}
|
{i > 0 ? ' · ' : ''}{pt.text}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -302,6 +338,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
|
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
|
||||||
|
if (s?.status === 'new-band-mode') return t('clg2.tipNewBandMode');
|
||||||
if (s?.status === 'new-band') return t('clg2.tipNewBand');
|
if (s?.status === 'new-band') return t('clg2.tipNewBand');
|
||||||
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
|
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
|
||||||
if (s?.status === 'new-call') return t('clg2.tipNewCall');
|
if (s?.status === 'new-call') return t('clg2.tipNewCall');
|
||||||
@@ -311,13 +348,26 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
headerName: t('clg2.c.pota'), width: 92, cellClass: 'font-mono',
|
||||||
|
// Through a valueGetter so the chase switch empties the column live.
|
||||||
|
valueGetter: (p: any) => (chasePota() ? p.data?.pota_ref ?? '' : ''),
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: (p: any) => (statusFor(p)?.new_pota
|
cellStyle: (p: any) => (statusFor(p)?.new_pota
|
||||||
? fillStyle(markerColour('new_pota'))
|
? fillStyle(markerColour('new_pota'))
|
||||||
: { color: 'var(--success)' }) as any,
|
: { color: 'var(--success)' }) as any,
|
||||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// SOTA sits next to POTA and reads the same way. The reference comes from the
|
||||||
|
// spot's comment, so it is present on the SOTA feeds and empty elsewhere —
|
||||||
|
// which is why the column is off by default rather than an empty column for
|
||||||
|
// everyone who does not watch summits.
|
||||||
|
group: 'Spot', label: t('clg2.c.sota'), colId: 'sota',
|
||||||
|
headerName: t('clg2.c.sota'), width: 100, cellClass: 'font-mono',
|
||||||
|
valueGetter: (p: any) => (chaseSota() ? p.data?.sota_ref ?? '' : ''),
|
||||||
|
defaultVisible: false,
|
||||||
|
cellStyle: () => ({ color: 'var(--success)' }) as any,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
|
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
|
||||||
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
@@ -330,10 +380,18 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// NEW BAND for this entity → the band cell is filled.
|
// NEW BAND for this entity → the band cell is filled. NEW BAND+MODE fills
|
||||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band' ? fillStyle(NEW) : null) as any,
|
// it too: the band really is new, and leaving it plain would say the
|
||||||
|
// opposite of the status column beside it.
|
||||||
|
cellStyle: (p: any) => {
|
||||||
|
const st = statusFor(p)?.status;
|
||||||
|
return (st === 'new-band' || st === 'new-band-mode' ? fillStyle(NEW) : null) as any;
|
||||||
|
},
|
||||||
cellRenderer: (p: any) => cellText(p.value, null),
|
cellRenderer: (p: any) => cellText(p.value, null),
|
||||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
tooltipValueGetter: (p: any) => {
|
||||||
|
const st = statusFor(p)?.status;
|
||||||
|
return st === 'new-band-mode' ? t('clg2.tipNewBandMode') : st === 'new-band' ? t('clg2.tipNewBand') : undefined;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
|
group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
|
||||||
@@ -341,14 +399,18 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||||
// Only NEW MODE pills the mode cell — there the mode itself is genuinely new
|
// NEW MODE and NEW BAND+MODE pill the mode cell — in both the mode itself is
|
||||||
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
// genuinely new for the entity. NEW SLOT means band AND mode were each worked
|
||||||
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
// before (just not together), so highlighting the mode cell would wrongly
|
||||||
// that case is signalled by the Status badge alone.
|
// imply "CW is new"; that case is signalled by the Status badge alone.
|
||||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-mode' ? fillStyle('var(--caution)') : null) as any,
|
cellStyle: (p: any) => {
|
||||||
|
const st = statusFor(p)?.status;
|
||||||
|
return (st === 'new-mode' || st === 'new-band-mode' ? fillStyle('var(--caution)') : null) as any;
|
||||||
|
},
|
||||||
cellRenderer: (p: any) => cellText(p.value, null),
|
cellRenderer: (p: any) => cellText(p.value, null),
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const st = statusFor(p)?.status;
|
const st = statusFor(p)?.status;
|
||||||
|
if (st === 'new-band-mode') return t('clg2.tipNewBandMode');
|
||||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||||
if (st === 'new-slot') return t('clg2.tipNewSlot');
|
if (st === 'new-slot') return t('clg2.tipNewSlot');
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -402,8 +464,13 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
|
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
|
||||||
headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
// The header carries the unit, so the cells stay bare numbers and the
|
||||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
// column still sorts on the km the backend sent — converting the VALUE
|
||||||
|
// would sort miles as if they were kilometres either way, but it would
|
||||||
|
// also round twice.
|
||||||
|
headerName: t('clg2.h.distance_km') + ' (' + distanceUnit() + ')',
|
||||||
|
field: 'distance_km' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
|
valueFormatter: (p) => p.value ? String(distanceValue(p.value)) : '',
|
||||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -476,13 +543,15 @@ const GROUP_ORDER = ['Spot', 'Geo'];
|
|||||||
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
|
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
|
||||||
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
|
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
|
||||||
|
|
||||||
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect, headerLeft }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
const [distUnit, setDistUnit] = useState(distanceUnit);
|
||||||
|
useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []);
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t, distUnit]);
|
||||||
|
|
||||||
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
|
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
|
||||||
// matching column events. Without this guard those events were persisted, so a
|
// matching column events. Without this guard those events were persisted, so a
|
||||||
@@ -558,7 +627,60 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
if (state) saveState(COL_STATE_KEY, state);
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// HOLDING THE LIST STILL WHILE IT IS BEING READ.
|
||||||
|
//
|
||||||
|
// A busy evening puts a spot on the list every second or two, each landing at
|
||||||
|
// the top and pushing everything below it down. At the top of the list that is
|
||||||
|
// exactly right — it is what makes the panel live. A few rows down it makes
|
||||||
|
// the panel unusable: the callsign under the pointer has moved by the time the
|
||||||
|
// click lands, and the operator ends up chasing the row they wanted.
|
||||||
|
//
|
||||||
|
// So the grid freezes as soon as it is scrolled away from the top, and says
|
||||||
|
// how many spots are waiting. Scrolling back to the top releases it, as does
|
||||||
|
// clicking the notice. Nothing is lost — the spots keep arriving in the shared
|
||||||
|
// list; this only decides when the GRID is allowed to redraw with them.
|
||||||
|
const [held, setHeld] = useState<ClusterSpot[] | null>(null);
|
||||||
|
const shown = held ?? rows;
|
||||||
|
|
||||||
|
// How many arrived since the freeze — counted by TIME, not by finding the
|
||||||
|
// frozen top row again.
|
||||||
|
//
|
||||||
|
// Looking for that row was wrong in the ordinary case: a station spotted again
|
||||||
|
// REPLACES its row (that is the de-dupe), so the row we froze on disappears
|
||||||
|
// from the live list the moment somebody re-spots it — and the count fell
|
||||||
|
// through to "everything is new", jumping from 4 to the buffer cap. Reported
|
||||||
|
// as "it shows 4, 5 new spots and then 500 all at once".
|
||||||
|
//
|
||||||
|
// A timestamp survives both the replacement and the ring buffer, which was the
|
||||||
|
// reason the length was not used either.
|
||||||
|
const spotTime = (r: ClusterSpot) => Date.parse(String((r as any).received_at ?? '')) || 0;
|
||||||
|
const waiting = useMemo(() => {
|
||||||
|
if (!held || held.length === 0) return 0;
|
||||||
|
const since = spotTime(held[0]);
|
||||||
|
if (!since) return 0; // no usable timestamp — say nothing rather than a number
|
||||||
|
let n = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (spotTime(r) > since) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}, [held, rows]);
|
||||||
|
|
||||||
|
const onBodyScroll = (e: { top: number }) => {
|
||||||
|
const down = e.top > 4; // a pixel or two of overscroll is not "scrolled down"
|
||||||
|
if (!down && held) setHeld(null);
|
||||||
|
if (down && !held) setHeld(rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
const release = () => {
|
||||||
|
setHeld(null);
|
||||||
|
gridRef.current?.api?.ensureIndexVisible(0, 'top');
|
||||||
|
};
|
||||||
|
|
||||||
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
|
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
|
||||||
|
if (e.data && onSpotSelect) onSpotSelect(e.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<ClusterSpot>) {
|
||||||
if (e.data && onSpotClick) onSpotClick(e.data);
|
if (e.data && onSpotClick) onSpotClick(e.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,7 +720,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
<div className="flex items-center gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||||
|
{headerLeft}
|
||||||
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||||
title={t('clg2.clearFiltersTitle')}>
|
title={t('clg2.clearFiltersTitle')}>
|
||||||
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
|
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
|
||||||
@@ -612,7 +736,7 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
<AgGridReact<ClusterSpot>
|
<AgGridReact<ClusterSpot>
|
||||||
ref={gridRef}
|
ref={gridRef}
|
||||||
theme={hamlogTheme}
|
theme={hamlogTheme}
|
||||||
rowData={rows}
|
rowData={shown}
|
||||||
columnDefs={columnDefs}
|
columnDefs={columnDefs}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
context={context}
|
context={context}
|
||||||
@@ -623,11 +747,21 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
onColumnVisible={saveColumnState}
|
onColumnVisible={saveColumnState}
|
||||||
onSortChanged={saveColumnState}
|
onSortChanged={saveColumnState}
|
||||||
onRowClicked={handleRowClicked}
|
onRowClicked={handleRowClicked}
|
||||||
|
onRowDoubleClicked={handleRowDoubleClicked}
|
||||||
|
onBodyScroll={onBodyScroll}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
suppressCellFocus
|
suppressCellFocus
|
||||||
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
|
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Only when something is actually waiting: a frozen list with nothing
|
||||||
|
new to show needs no announcement. */}
|
||||||
|
{waiting > 0 && (
|
||||||
|
<button type="button" onClick={release}
|
||||||
|
className="absolute left-1/2 -translate-x-1/2 top-1 z-10 rounded-full border border-primary bg-primary px-3 py-1 text-[11px] font-semibold text-primary-foreground shadow-lg hover:opacity-90">
|
||||||
|
{t('clg2.newSpots', { n: waiting })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
@@ -638,6 +772,17 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
{t('clg2.pickerDesc')}
|
{t('clg2.pickerDesc')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{/* Whole-dialog controls. showAll/hideAll with no group already mean
|
||||||
|
"every column"; only the buttons were missing, so hiding the lot
|
||||||
|
took one click per group. */}
|
||||||
|
<div className="flex items-center gap-1 px-1 text-[11px] text-muted-foreground">
|
||||||
|
<span>{t('clg2.allGroups')}</span>
|
||||||
|
<button type="button" className="text-[11px] text-primary hover:underline px-1"
|
||||||
|
onClick={() => showAll()}>{t('clg2.all')}</button>
|
||||||
|
<span className="opacity-40">·</span>
|
||||||
|
<button type="button" className="text-[11px] text-muted-foreground hover:underline px-1"
|
||||||
|
onClick={() => hideAll()}>{t('clg2.none')}</button>
|
||||||
|
</div>
|
||||||
<div className="max-h-[60vh] overflow-y-auto py-2">
|
<div className="max-h-[60vh] overflow-y-auto py-2">
|
||||||
{GROUP_ORDER.map((group) => {
|
{GROUP_ORDER.map((group) => {
|
||||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { RefreshCw, Star, ExternalLink } from 'lucide-react';
|
||||||
|
import { GetDXpeditions, GetDXWorldNews, RefreshDXpeditions, WatchlistEntries, WatchlistAdd } from '../../wailsjs/go/main/App';
|
||||||
|
import { BrowserOpenURL, EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// DXpeditions — the two feeds the DX world announces itself on, side by side.
|
||||||
|
//
|
||||||
|
// Left: NG3K's ADXO, the structured announcements, each judged against THIS log
|
||||||
|
// so the list reads as "what I still need" rather than "what is on". Right:
|
||||||
|
// DX-World's headlines, whose callsigns are mined out of the title so they can
|
||||||
|
// be watched with the same one click.
|
||||||
|
|
||||||
|
type DXped = {
|
||||||
|
dxcc: string; callsign: string; calls?: string[];
|
||||||
|
start_date: string; end_date: string;
|
||||||
|
bands?: string[]; modes?: string[];
|
||||||
|
qsl: string; operators: string; source: string; link: string;
|
||||||
|
status: string; // active | upcoming
|
||||||
|
status_chase: string; // new | new-band-mode | new-band | new-mode | new-slot | worked | ''
|
||||||
|
unconfirmed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type News = {
|
||||||
|
title: string; link: string; pub_date: string; excerpt: string;
|
||||||
|
creator: string; image_url: string; tag: string; calls?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// One badge per expedition, the strongest verdict winning — the same palette
|
||||||
|
// and the same words the cluster uses, so the two views teach one vocabulary.
|
||||||
|
const CHASE_BADGE: Record<string, { label: string; colour: string }> = {
|
||||||
|
'new': { label: 'clg2.newDxcc', colour: 'var(--danger)' },
|
||||||
|
'new-band-mode': { label: 'clg2.newBandMode', colour: 'var(--danger)' },
|
||||||
|
'new-band': { label: 'clg2.newBand', colour: 'var(--warning)' },
|
||||||
|
'new-mode': { label: 'clg2.newMode', colour: 'var(--caution)' },
|
||||||
|
'new-slot': { label: 'clg2.newSlot', colour: '#5AC8FA' },
|
||||||
|
'worked': { label: 'wl.worked', colour: 'var(--info)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DXpeditionsPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [peds, setPeds] = useState<DXped[]>([]);
|
||||||
|
const [news, setNews] = useState<News[]>([]);
|
||||||
|
const [watched, setWatched] = useState<Set<string>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [neededOnly, setNeededOnly] = useState(() => localStorage.getItem('opslog.dxpedNeeded') === '1');
|
||||||
|
|
||||||
|
const loadWatchlist = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const e: any[] = await WatchlistEntries();
|
||||||
|
setWatched(new Set((e ?? []).map((x) => String(x.callsign ?? '').toUpperCase())));
|
||||||
|
} catch { /* the list simply shows every call as unwatched */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setErr('');
|
||||||
|
const [p, n] = await Promise.allSettled([GetDXpeditions(), GetDXWorldNews()]);
|
||||||
|
if (p.status === 'fulfilled') setPeds((p.value as any) ?? []);
|
||||||
|
else setErr(String((p.reason as any)?.message ?? p.reason));
|
||||||
|
if (n.status === 'fulfilled') setNews((n.value as any) ?? []);
|
||||||
|
setBusy(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); void loadWatchlist(); }, [load, loadWatchlist]);
|
||||||
|
useEffect(() => EventsOn('watchlist:changed', () => { void loadWatchlist(); }), [loadWatchlist]);
|
||||||
|
|
||||||
|
const refresh = async () => { await RefreshDXpeditions(); await load(); };
|
||||||
|
|
||||||
|
const addAll = async (calls: string[]) => {
|
||||||
|
for (const c of calls) {
|
||||||
|
if (!watched.has(c.toUpperCase())) {
|
||||||
|
try { await WatchlistAdd(c, false); } catch { /* reported by the notice */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadWatchlist();
|
||||||
|
};
|
||||||
|
|
||||||
|
const shown = useMemo(
|
||||||
|
() => (neededOnly ? peds.filter((p) => p.status_chase && p.status_chase !== 'worked') : peds),
|
||||||
|
[peds, neededOnly]);
|
||||||
|
|
||||||
|
// A row's calls: the mined ones, else whatever the announcement called it.
|
||||||
|
const callsOf = (p: DXped) => (p.calls?.length ? p.calls : p.callsign ? [p.callsign] : []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 gap-3">
|
||||||
|
{/* ── Announcements (ADXO) ── */}
|
||||||
|
<section className="flex flex-col min-h-0 flex-[3] rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||||
|
<span className="text-sm font-semibold">{t('dxp.announced')}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'NG3K ADXO' })}</span>
|
||||||
|
<label className="ml-auto flex items-center gap-1.5 text-[11px] cursor-pointer text-muted-foreground hover:text-foreground">
|
||||||
|
<input type="checkbox" checked={neededOnly}
|
||||||
|
onChange={(e) => { setNeededOnly(e.target.checked); localStorage.setItem('opslog.dxpedNeeded', e.target.checked ? '1' : '0'); }} />
|
||||||
|
{t('dxp.neededOnly')}
|
||||||
|
</label>
|
||||||
|
<Button variant="outline" size="sm" onClick={refresh} disabled={busy}>
|
||||||
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('dxp.refresh')}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{err && <div className="px-3 py-2 text-xs text-danger shrink-0">{err}</div>}
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||||
|
{shown.length === 0 && !busy && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.none')}</p>
|
||||||
|
)}
|
||||||
|
{shown.map((p, i) => {
|
||||||
|
const calls = callsOf(p);
|
||||||
|
const badge = CHASE_BADGE[p.status_chase];
|
||||||
|
const allWatched = calls.length > 0 && calls.every((c) => watched.has(c.toUpperCase()));
|
||||||
|
return (
|
||||||
|
<article key={`${p.callsign}-${p.start_date}-${i}`}
|
||||||
|
className={cn('rounded-md border p-2 text-xs',
|
||||||
|
p.status === 'active' ? 'border-success/40 bg-success/5' : 'border-border bg-muted/20')}>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-mono font-bold text-sm text-info">{p.callsign || '—'}</span>
|
||||||
|
<span className="font-medium">{p.dxcc}</span>
|
||||||
|
{p.status === 'active' && (
|
||||||
|
<span className="px-1 py-px rounded text-[10px] font-bold uppercase bg-success-muted text-success-muted-foreground">
|
||||||
|
{t('dxp.onAir')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{badge && (
|
||||||
|
<span className="px-1 py-px rounded text-[10px] font-bold uppercase tracking-wide border"
|
||||||
|
title={p.unconfirmed ? t('dec.unconfTip') : undefined}
|
||||||
|
style={p.unconfirmed
|
||||||
|
? { color: badge.colour, borderColor: badge.colour, borderStyle: 'dashed', opacity: 0.6 }
|
||||||
|
: { color: badge.colour, borderColor: badge.colour }}>
|
||||||
|
{t(badge.label)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-[11px] text-muted-foreground whitespace-nowrap">
|
||||||
|
{p.start_date} → {p.end_date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex items-center gap-2 flex-wrap text-[11px] text-muted-foreground">
|
||||||
|
{!!p.bands?.length && <span>{p.bands.join(' · ')}</span>}
|
||||||
|
{!!p.modes?.length && <span className="text-foreground/70">{p.modes.join(' ')}</span>}
|
||||||
|
{p.qsl && <span>QSL: {p.qsl}</span>}
|
||||||
|
{p.source && <span>· {p.source}</span>}
|
||||||
|
</div>
|
||||||
|
{p.operators && <p className="mt-0.5 text-[11px] text-muted-foreground truncate">{p.operators}</p>}
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<Button variant={allWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
|
||||||
|
disabled={calls.length === 0 || allWatched}
|
||||||
|
onClick={() => addAll(calls)}
|
||||||
|
title={t('dxp.watchTip')}>
|
||||||
|
<Star className={cn('size-3', allWatched && 'fill-current text-warning')} />
|
||||||
|
{allWatched ? t('dxp.watched') : t('dxp.watch')}
|
||||||
|
</Button>
|
||||||
|
{p.link && (
|
||||||
|
<button type="button" onClick={() => BrowserOpenURL(p.link)}
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
|
||||||
|
<ExternalLink className="size-3" /> {t('dxp.open')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── News (DX-World) ── */}
|
||||||
|
<section className="flex flex-col min-h-0 flex-[2] rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||||
|
<span className="text-sm font-semibold">{t('dxp.news')}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'DX-World' })}</span>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||||
|
{news.length === 0 && !busy && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.noNews')}</p>
|
||||||
|
)}
|
||||||
|
{news.map((n, i) => {
|
||||||
|
const newsCalls = n.calls ?? [];
|
||||||
|
const newsAllWatched = newsCalls.length > 0 && newsCalls.every((c) => watched.has(c.toUpperCase()));
|
||||||
|
return (
|
||||||
|
<article key={`${n.link}-${i}`} className="rounded-md border border-border bg-muted/20 p-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{n.image_url && (
|
||||||
|
<img src={n.image_url} alt="" className="size-12 rounded object-cover shrink-0"
|
||||||
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{n.tag && (
|
||||||
|
<span className="px-1 py-px rounded text-[9px] font-bold uppercase bg-primary/15 text-primary">{n.tag}</span>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => n.link && BrowserOpenURL(n.link)}
|
||||||
|
className="text-xs font-medium text-left hover:underline">{n.title}</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-[11px] text-muted-foreground line-clamp-3">{n.excerpt}</p>
|
||||||
|
{/* The callsigns are shown, not clicked: watching is the same
|
||||||
|
one button as an announcement, so the gesture is learned
|
||||||
|
once for the whole tab. */}
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
|
||||||
|
{n.pub_date && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{new Date(n.pub_date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(n.calls ?? []).map((c) => (
|
||||||
|
<span key={c} className={cn('font-mono text-[10px]',
|
||||||
|
watched.has(c.toUpperCase()) ? 'text-warning' : 'text-info')}>
|
||||||
|
{c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<Button variant={newsAllWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
|
||||||
|
disabled={newsCalls.length === 0 || newsAllWatched}
|
||||||
|
onClick={() => addAll(newsCalls)}
|
||||||
|
title={t('dxp.watchTip')}>
|
||||||
|
<Star className={cn('size-3', newsAllWatched && 'fill-current text-warning')} />
|
||||||
|
{newsAllWatched ? t('dxp.watched') : t('dxp.watch')}
|
||||||
|
</Button>
|
||||||
|
{n.link && (
|
||||||
|
<button type="button" onClick={() => BrowserOpenURL(n.link)}
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
|
||||||
|
<ExternalLink className="size-3" /> {t('dxp.open')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
||||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
@@ -42,6 +43,7 @@ export interface DetailsState {
|
|||||||
srx_string?: string;
|
srx_string?: string;
|
||||||
stx_string?: string;
|
stx_string?: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
web?: string;
|
||||||
// Award references for the contacted station (set via the Awards tab picker).
|
// Award references for the contacted station (set via the Awards tab picker).
|
||||||
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
||||||
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
||||||
@@ -69,6 +71,11 @@ interface Props {
|
|||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
bands?: string[]; // configured bands for the worked-before matrix columns
|
bands?: string[]; // configured bands for the worked-before matrix columns
|
||||||
|
modes?: string[]; // configured modes, in order — the matrix cycles its digital row through them
|
||||||
|
// The station's satellites, for the SAT_NAME dropdown. Passed in rather than
|
||||||
|
// read here: the list lives in Preferences, and App already reloads it when
|
||||||
|
// Preferences close — a panel reading it once at mount would need a restart.
|
||||||
|
satellites?: string[];
|
||||||
// When the entry form is empty and a QSO is selected in the log grid, the
|
// When the entry form is empty and a QSO is selected in the log grid, the
|
||||||
// Stats (F1) matrix shows THAT contact's entity instead of sitting blank.
|
// Stats (F1) matrix shows THAT contact's entity instead of sitting blank.
|
||||||
// Only the matrix is redirected — every other tab still edits the live entry.
|
// Only the matrix is redirected — every other tab still edits the live entry.
|
||||||
@@ -149,7 +156,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, modes, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
@@ -288,6 +295,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
currentBand={slotCall ? (slotBand ?? '') : band}
|
currentBand={slotCall ? (slotBand ?? '') : band}
|
||||||
currentMode={slotCall ? (slotMode ?? '') : mode}
|
currentMode={slotCall ? (slotMode ?? '') : mode}
|
||||||
bands={bands}
|
bands={bands}
|
||||||
|
modes={modes}
|
||||||
hasCall={slotCall ? true : callsign.trim() !== ''}
|
hasCall={slotCall ? true : callsign.trim() !== ''}
|
||||||
forCall={slotCall}
|
forCall={slotCall}
|
||||||
onEditQso={onEditQso}
|
onEditQso={onEditQso}
|
||||||
@@ -373,12 +381,16 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label={t('detp.qslMessage')} span={7}>
|
{/* QSL via gets the room, not the message. Width should follow use, and
|
||||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
these two are nowhere near equal: a manager's callsign is filled in
|
||||||
</Field>
|
constantly and a QSL message almost never. The message had 7 columns
|
||||||
<Field label={t('detp.qslVia')} span={5}>
|
of 12 for text most operators never type. */}
|
||||||
|
<Field label={t('detp.qslVia')} span={7}>
|
||||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label={t('detp.qslMessage')} span={5}>
|
||||||
|
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -473,7 +485,25 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
{satelliteMode && (
|
{satelliteMode && (
|
||||||
<>
|
<>
|
||||||
<Field label={t('detp.satName')} span={3}>
|
<Field label={t('detp.satName')} span={3}>
|
||||||
<Input value={details.sat_name} onChange={(e) => onChange({ sat_name: e.target.value })} />
|
{/* The station's own satellites, in alphabetical order, with the
|
||||||
|
box still open to anything typed: SAT_NAME is compared
|
||||||
|
character for character by the awards and by LoTW, so a
|
||||||
|
remembered spelling beats a fresh one every pass — but a bird
|
||||||
|
worked once and never added to the list must not be
|
||||||
|
impossible to log. */}
|
||||||
|
{/* showToggle: without the chevron this control is a text box that
|
||||||
|
happens to open on a keystroke, which nobody discovers — and
|
||||||
|
is exactly what "there is still no dropdown" meant.
|
||||||
|
allowFreeText: the list is the station's own, so a bird
|
||||||
|
worked once and never added to it must still be loggable. */}
|
||||||
|
<Combobox
|
||||||
|
value={details.sat_name}
|
||||||
|
options={satellites}
|
||||||
|
placeholder={t('detp.satName')}
|
||||||
|
showToggle
|
||||||
|
allowFreeText
|
||||||
|
onChange={(v) => onChange({ sat_name: v })}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.satelliteMode')} span={3}>
|
<Field label={t('detp.satelliteMode')} span={3}>
|
||||||
<Input value={details.sat_mode} onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
<Input value={details.sat_mode} onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||||
@@ -494,8 +524,14 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
<Field label="STX">
|
<Field label="STX">
|
||||||
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.contactedEmail')} span={3}>
|
{/* No placeholder: a greyed-out sample address in an empty field reads
|
||||||
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
|
as a value the lookup found, and that is exactly the field an
|
||||||
|
operator checks to see whether it found one. */}
|
||||||
|
<Field label={t('detp.contactedEmail')} span={2}>
|
||||||
|
<Input value={details.email} onChange={(e) => onChange({ email: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('detp.contactedWeb')}>
|
||||||
|
<Input value={details.web ?? ''} onChange={(e) => onChange({ web: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type Props = {
|
|||||||
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
|
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
|
||||||
};
|
};
|
||||||
|
|
||||||
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F12
|
||||||
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
||||||
// the reserved area. Recording/labeling lives in Settings → Audio.
|
// the reserved area. Recording/labeling lives in Settings → Audio.
|
||||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Radio, Power, Activity, AudioLines, SlidersHorizontal } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU,
|
||||||
|
SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt,
|
||||||
|
SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna,
|
||||||
|
SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, SetKenwoodRITOffset, SetKenwoodPanelMode,
|
||||||
|
GetCATState,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { sMeterRST } from '@/lib/rst';
|
||||||
|
import { ShiftRow } from '@/components/ShiftRow';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import { WheelRange } from '@/components/WheelRange';
|
||||||
|
|
||||||
|
type KenwoodState = {
|
||||||
|
available: boolean; model?: string; elecraft: boolean; mode?: string; data_sub?: string;
|
||||||
|
transmitting: boolean; split: boolean; split_tx_hz?: number;
|
||||||
|
s_meter: number; s_meter_raw: number;
|
||||||
|
power_meter: number; power_w?: number; swr: number; swr_raw: number;
|
||||||
|
rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number;
|
||||||
|
preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string;
|
||||||
|
filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number;
|
||||||
|
meters_provisional: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZERO: KenwoodState = {
|
||||||
|
available: false, elecraft: false, transmitting: false, split: false,
|
||||||
|
s_meter: 0, s_meter_raw: 0, power_meter: 0, swr: 0, swr_raw: 0,
|
||||||
|
rf_power: 0, af_gain: 0, rf_gain: 0, mic_gain: 0, squelch: 0,
|
||||||
|
preamp: false, att: false, nb: false, nr: false,
|
||||||
|
filter_hz: 0, antenna: 0, rit: false, xit: false, rit_offset: 0, key_speed: 0,
|
||||||
|
meters_provisional: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter widths worth a button. CW work happens at 200-500 Hz, SSB at 2.4-2.8
|
||||||
|
// kHz; the rest of the range is reachable from the radio's own knob, and a
|
||||||
|
// panel offering thirty widths is slower to use than the knob it replaces.
|
||||||
|
// 4.0k is the K3 maximum and the one FT8 wants: a 2.8 kHz filter clips the
|
||||||
|
// top of the FT8 sub-band, and the decodes that go missing are the ones
|
||||||
|
// nobody notices are missing.
|
||||||
|
const FILTERS = [200, 400, 700, 1000, 1800, 2400, 2800, 4000];
|
||||||
|
|
||||||
|
// Raw S-meter → S units, CALIBRATED against a real K3 beside its own display
|
||||||
|
// (2026-08): raw 5 reads S7 on the radio, raw 9 reads S9+20. So S9 sits near
|
||||||
|
// raw 6.5 — not 9, which showed everything two S-units low — and each raw step
|
||||||
|
// above it is worth ~8 dB, shown in the 10 dB steps the K3's own meter uses.
|
||||||
|
// A Kenwood answers 0-30 on the same command and keeps the simple 1-per-raw
|
||||||
|
// scale until someone calibrates one against a real radio too.
|
||||||
|
const K3_S9_RAW = 6.5;
|
||||||
|
const K3_DB_PER_RAW = 8;
|
||||||
|
function sParts(rawV: number, elecraft: boolean): { s: number; over: number; label: string } {
|
||||||
|
const s9raw = elecraft ? K3_S9_RAW : 9;
|
||||||
|
if (rawV >= s9raw) {
|
||||||
|
let over = Math.max(0, Math.round((rawV - s9raw) * (elecraft ? K3_DB_PER_RAW : 6)));
|
||||||
|
if (elecraft) over = Math.round(over / 10) * 10;
|
||||||
|
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||||
|
}
|
||||||
|
const s = Math.max(0, Math.min(9, Math.round(rawV * (elecraft ? 9 / K3_S9_RAW : 1))));
|
||||||
|
return { s, over: 0, label: `S${s}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment colour, printed the way a radio's own meter is: green up to S9, amber
|
||||||
|
// through the S9+ range, red once the signal would be reported as 59+20 or more.
|
||||||
|
function sSegColor(frac: number) {
|
||||||
|
if (frac > 0.78) return '#dc2626';
|
||||||
|
if (frac > 0.55) return '#f59e0b';
|
||||||
|
return '#16a34a';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The card shell the Yaesu and Icom consoles use, so the three read alike.
|
||||||
|
function Card({ icon: Icon, title, children }: { icon: any; title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Icon className="size-4 text-primary" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One button shape for every on/off control, so the panel reads as one
|
||||||
|
// instrument instead of a collection of differently-styled switches.
|
||||||
|
function Toggle({ label, on, off, onClick }: { label: string; on: boolean; off: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button type="button" disabled={off} onClick={onClick}
|
||||||
|
className={cn('px-2 py-1 rounded-md text-[11px] font-bold tracking-wide border transition-all disabled:opacity-30',
|
||||||
|
on ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:border-primary/60')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDataMode: what the rig reports for MD6 varies — "DATA", or the configured
|
||||||
|
// digital default (FT8…) — so the DATA family is "not one of the native modes".
|
||||||
|
function isDataMode(m?: string): boolean {
|
||||||
|
switch ((m ?? '').toUpperCase()) {
|
||||||
|
case '': case 'CW': case 'USB': case 'LSB': case 'SSB': case 'FM': case 'AM': case 'RTTY':
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [st, setSt] = useState<KenwoodState>(ZERO);
|
||||||
|
// Ctrl+Left/Right shifts the RIT by ±10 Hz while RIT is on — the same
|
||||||
|
// keyboard clarifier the Icom and TCI consoles have, for zero-beating a
|
||||||
|
// caller without touching the mouse.
|
||||||
|
const stRef = useRef(st); stRef.current = st;
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
||||||
|
const v = stRef.current;
|
||||||
|
if (!v.available || !v.rit) return;
|
||||||
|
e.preventDefault();
|
||||||
|
SetKenwoodRITOffset((v.rit_offset || 0) + (e.key === 'ArrowRight' ? 10 : -10)).catch(() => {});
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
const [freqHz, setFreqHz] = useState(0);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
// Optimistic overlay: a slider must follow the finger, not the poll. Dropped
|
||||||
|
// once the radio has had time to answer with the value it actually took.
|
||||||
|
const [local, setLocal] = useState<{ rf_power?: number; af_gain?: number; rf_gain?: number; mic_gain?: number; squelch?: number; key_speed?: number }>({});
|
||||||
|
const localAtRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const s = (await GetKenwoodState()) as KenwoodState;
|
||||||
|
const c = (await GetCATState()) as any;
|
||||||
|
if (!alive) return;
|
||||||
|
setSt(s);
|
||||||
|
setFreqHz(c?.split && c?.freq_rx_hz > 0 ? c.freq_rx_hz : (c?.freq_hz ?? 0));
|
||||||
|
if (Date.now() - localAtRef.current > 1200) setLocal({});
|
||||||
|
setErr('');
|
||||||
|
} catch (e: any) {
|
||||||
|
if (alive) setErr(String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const view = { ...st, ...local };
|
||||||
|
const off = !st.available;
|
||||||
|
|
||||||
|
const setErrMsg = (e: any) => setErr(String(e?.message ?? e));
|
||||||
|
|
||||||
|
const put = (patch: typeof local, run: () => Promise<any>) => {
|
||||||
|
setLocal((p) => ({ ...p, ...patch }));
|
||||||
|
localAtRef.current = Date.now();
|
||||||
|
run().catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
|
{/* Same wrapper as the Yaesu, Icom and Flex consoles: capped width and
|
||||||
|
CENTRED. A console stretched across a 2000 px window puts a slider a
|
||||||
|
hand's width from its own label, and the panel stops reading as one
|
||||||
|
instrument. */}
|
||||||
|
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||||
|
{/* VFO + status */}
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Radio className="size-3.5" />
|
||||||
|
{st.elecraft ? 'Elecraft' : 'Kenwood'} {st.model}
|
||||||
|
<span className={cn('size-2 rounded-full', off ? 'bg-muted-foreground/40' : 'bg-success')} />
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-mono tabular-nums font-bold">
|
||||||
|
{freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Mode row. The two DATA buttons are why it exists: MD6 alone keeps
|
||||||
|
whatever submode the last session left, and a K3 "in DATA" with FSK D
|
||||||
|
still armed keys FT8 with no audio. DATA sends MD6+DT0 (DATA A, the
|
||||||
|
soundcard path), RTTY sends MD6+DT2 (FSK D). The active DATA button
|
||||||
|
follows the rig's own DT answer. */}
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{([
|
||||||
|
['CW', 'CW', view.mode === 'CW'],
|
||||||
|
['USB', 'USB', view.mode === 'USB'],
|
||||||
|
['LSB', 'LSB', view.mode === 'LSB'],
|
||||||
|
['DATA', t('k3.modeData'), isDataMode(view.mode) && view.data_sub !== 'FSK D' && view.data_sub !== 'PSK D'],
|
||||||
|
['RTTY', t('k3.modeRtty'), (isDataMode(view.mode) && (view.data_sub === 'FSK D' || view.data_sub === 'PSK D')) || view.mode === 'RTTY'],
|
||||||
|
] as [string, string, boolean][]).map(([cmd, label, on]) => (
|
||||||
|
<button key={cmd} type="button" disabled={off}
|
||||||
|
title={cmd === 'DATA' ? t('k3.modeDataHint') : cmd === 'RTTY' ? t('k3.modeRttyHint') : label}
|
||||||
|
onClick={() => SetKenwoodPanelMode(cmd).catch((e: any) => setErr(String(e?.message ?? e)))}
|
||||||
|
className={cn('px-2.5 py-1.5 text-xs font-bold border-l border-border first:border-l-0',
|
||||||
|
on ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||||
|
onClick={() => RefreshKenwood().catch(() => {})} title={t('k3.refreshHint')}>
|
||||||
|
<SlidersHorizontal className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{off && <div className="text-xs text-muted-foreground px-1">{t('k3.waiting')}</div>}
|
||||||
|
{!!err && <div className="text-[11px] text-danger px-1">{err}</div>}
|
||||||
|
|
||||||
|
{/* Meters */}
|
||||||
|
<Card icon={Activity} title={t('k3.meters')}>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
<MeterBar label="S-METER" value={view.transmitting ? 0 : view.s_meter} lo={0} hi={100}
|
||||||
|
accent="#16a34a" segColor={sSegColor}
|
||||||
|
display={view.transmitting ? '—' : sParts(view.s_meter_raw, view.elecraft).label}
|
||||||
|
onClick={() => {
|
||||||
|
if (view.transmitting || !onReportRST) return;
|
||||||
|
const sp = sParts(view.s_meter_raw, view.elecraft);
|
||||||
|
onReportRST(sMeterRST(sp.s, sp.over, view.mode));
|
||||||
|
}}
|
||||||
|
title={t('k3.sMeterHint', { raw: String(view.s_meter_raw) })} />
|
||||||
|
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9"
|
||||||
|
display={view.transmitting && view.elecraft ? `${view.power_w ?? 0} W` : undefined} />
|
||||||
|
{/* 0 means "not measured", and it must not render as a perfect 1.0:
|
||||||
|
a match that looks ideal on an antenna nobody has measured is the one
|
||||||
|
reading that can cost a radio. */}
|
||||||
|
<MeterBar label="SWR" value={view.transmitting && view.swr > 0 ? view.swr : 1} lo={1} hi={4}
|
||||||
|
accent="#f59e0b"
|
||||||
|
display={view.transmitting && view.swr > 0 ? view.swr.toFixed(1) : '—'} />
|
||||||
|
</div>
|
||||||
|
{view.meters_provisional && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t('k3.provisional')}</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* MOX + TUNE */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
onClick={() => SetKenwoodTX(!view.transmitting).catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
view.transmitting
|
||||||
|
? 'bg-danger text-danger-foreground border-danger shadow-[0_0_14px] shadow-danger/50'
|
||||||
|
: 'bg-card text-danger border-danger hover:bg-danger-muted')}>
|
||||||
|
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
onClick={() => TuneKenwoodATU().catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('k3.tuneHint')}
|
||||||
|
className="flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 border-warning text-warning bg-card hover:bg-warning-muted transition-all disabled:opacity-30">
|
||||||
|
<Activity className="size-4 inline mr-1 -mt-0.5" /> TUNE
|
||||||
|
</button>
|
||||||
|
{/* The HOLD of the same switch: tuner in line or bypassed. Two buttons
|
||||||
|
because they are two things on the radio — tuning is a cycle you
|
||||||
|
start, bypassing is a state you leave it in. */}
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
onClick={() => ToggleKenwoodATU().catch(setErrMsg)}
|
||||||
|
title={t('k3.atuHint')}
|
||||||
|
className="px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 border-border text-muted-foreground bg-card hover:bg-muted transition-all disabled:opacity-30">
|
||||||
|
ATU
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Levels — two columns, so a slider stays beside the label it belongs to
|
||||||
|
instead of running the width of the window. */}
|
||||||
|
<Card icon={SlidersHorizontal} title={t('k3.levels')}>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.power')}</span>
|
||||||
|
<WheelRange min={0} max={110} step={1} disabled={off}
|
||||||
|
value={view.rf_power ?? 0}
|
||||||
|
onChange={(n) => put({ rf_power: n }, () => SetKenwoodPower(n))} />
|
||||||
|
<span className="w-12 text-right font-mono tabular-nums">{view.rf_power ?? 0} W</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-16 shrink-0 text-muted-foreground flex items-center gap-1">
|
||||||
|
<AudioLines className="size-3.5" /> {t('k3.volume')}
|
||||||
|
</span>
|
||||||
|
<WheelRange min={0} max={100} disabled={off}
|
||||||
|
value={view.af_gain ?? 0}
|
||||||
|
onChange={(n) => put({ af_gain: n }, () => SetKenwoodAFGain(n))} />
|
||||||
|
<span className="w-12 text-right font-mono tabular-nums">{view.af_gain ?? 0}</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.rfGain')}</span>
|
||||||
|
<WheelRange min={0} max={100} disabled={off}
|
||||||
|
value={view.rf_gain ?? 0}
|
||||||
|
onChange={(n) => put({ rf_gain: n }, () => SetKenwoodRFGain(n))} />
|
||||||
|
<span className="w-12 text-right font-mono tabular-nums">{view.rf_gain ?? 0}</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.micGain')}</span>
|
||||||
|
<WheelRange min={0} max={100} disabled={off}
|
||||||
|
value={view.mic_gain ?? 0}
|
||||||
|
onChange={(n) => put({ mic_gain: n }, () => SetKenwoodMicGain(n))} />
|
||||||
|
<span className="w-12 text-right font-mono tabular-nums">{view.mic_gain ?? 0}</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.squelch')}</span>
|
||||||
|
<WheelRange min={0} max={100} disabled={off}
|
||||||
|
value={view.squelch ?? 0}
|
||||||
|
onChange={(n) => put({ squelch: n }, () => SetKenwoodSquelch(n))} />
|
||||||
|
<span className="w-12 text-right font-mono tabular-nums">{view.squelch ?? 0}</span>
|
||||||
|
</label>
|
||||||
|
{/* CW keyer speed — the radio's own keyer, the one the K3 sends with. */}
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.keySpeed')}</span>
|
||||||
|
<WheelRange min={8} max={50} disabled={off}
|
||||||
|
value={view.key_speed || 20}
|
||||||
|
onChange={(n) => put({ key_speed: n }, () => SetKenwoodKeySpeed(n))} />
|
||||||
|
<span className="w-12 text-right font-mono tabular-nums">{view.key_speed || 0} wpm</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Receive chain. Toggles, because that is what they are on the radio:
|
||||||
|
one press each, and the state comes back from the rig rather than from
|
||||||
|
what the button was asked to do. */}
|
||||||
|
<Card icon={AudioLines} title={t('k3.receive')}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<Toggle label="PRE" on={view.preamp} off={off} onClick={() => SetKenwoodPreamp(!view.preamp).catch(setErrMsg)} />
|
||||||
|
<Toggle label="ATT" on={view.att} off={off} onClick={() => SetKenwoodAtt(!view.att).catch(setErrMsg)} />
|
||||||
|
<Toggle label="NB" on={view.nb} off={off} onClick={() => SetKenwoodNB(!view.nb).catch(setErrMsg)} />
|
||||||
|
<Toggle label="NR" on={view.nr} off={off} onClick={() => SetKenwoodNR(!view.nr).catch(setErrMsg)} />
|
||||||
|
<span className="w-2" />
|
||||||
|
{['OFF', 'SLOW', 'FAST'].map((a) => (
|
||||||
|
<Toggle key={a} label={a} on={(view.agc || '').toUpperCase() === a} off={off}
|
||||||
|
onClick={() => SetKenwoodAGC(a).catch(setErrMsg)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIT / XIT — the shared ShiftRow, exactly as the Icom and TCI consoles
|
||||||
|
drive theirs: ± / wheel / type, Ctrl+←/→ while RIT is on. The K3 has
|
||||||
|
ONE offset for both, so both rows show it, like the Icom's. */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<ShiftRow label="RIT" accent="#8b5cf6" on={view.rit} hz={view.rit_offset || 0} disabled={off}
|
||||||
|
onToggle={() => SetKenwoodRIT(!view.rit).catch(setErrMsg)}
|
||||||
|
onSet={(hz) => SetKenwoodRITOffset(hz).catch(setErrMsg)} />
|
||||||
|
<ShiftRow label="XIT" accent="#f59e0b" on={view.xit} hz={view.rit_offset || 0} disabled={off}
|
||||||
|
onToggle={() => SetKenwoodXIT(!view.xit).catch(setErrMsg)}
|
||||||
|
onSet={(hz) => SetKenwoodRITOffset(hz).catch(setErrMsg)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{/* Antenna only when the radio answered AN — a K3 without the internal
|
||||||
|
ATU has one socket and no switch to offer. */}
|
||||||
|
{view.antenna > 0 && (<>
|
||||||
|
<span className="w-2" />
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('k3.antenna')}</span>
|
||||||
|
{[1, 2].map((n) => (
|
||||||
|
<Toggle key={n} label={'ANT' + n} on={view.antenna === n} off={off}
|
||||||
|
onClick={() => SetKenwoodAntenna(n).catch(setErrMsg)} />
|
||||||
|
))}
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter width */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('k3.filter')}</span>
|
||||||
|
{FILTERS.map((hz) => (
|
||||||
|
<Toggle key={hz} label={hz >= 1000 ? (hz / 1000).toFixed(1) + 'k' : String(hz)}
|
||||||
|
on={view.filter_hz === hz} off={off}
|
||||||
|
onClick={() => SetKenwoodFilter(hz).catch(setErrMsg)} />
|
||||||
|
))}
|
||||||
|
{view.filter_hz > 0 && (
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground">{view.filter_hz} Hz</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -86,6 +86,13 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
|||||||
return [...m.entries()];
|
return [...m.entries()];
|
||||||
}, [official]);
|
}, [official]);
|
||||||
|
|
||||||
|
// Every tag the dialog can offer — the extras AND every group — so "select
|
||||||
|
// all" means what it says rather than "all the official ones".
|
||||||
|
const everyTag = useMemo(
|
||||||
|
() => [...extras, ...groups.flatMap(([, list]) => list.map((d) => d.name))],
|
||||||
|
[extras, groups],
|
||||||
|
);
|
||||||
|
|
||||||
const toggle = (name: string, on: boolean) =>
|
const toggle = (name: string, on: boolean) =>
|
||||||
setSel((s) => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; });
|
setSel((s) => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; });
|
||||||
const setMany = (names: string[], on: boolean) =>
|
const setMany = (names: string[], on: boolean) =>
|
||||||
@@ -114,11 +121,25 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
|||||||
<DialogDescription>{t('exf.desc')}</DialogDescription>
|
<DialogDescription>{t('exf.desc')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Whole-dialog select/clear, next to the count they change. Per-group
|
||||||
|
links alone meant clearing the selection was one click PER GROUP —
|
||||||
|
a dozen of them to get to "just the handful I want". */}
|
||||||
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
|
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
|
||||||
<span>{t('exf.chosen', { n: sel.size })}</span>
|
<span>{t('exf.chosen', { n: sel.size })}</span>
|
||||||
<Button variant="ghost" size="sm" className="ml-auto h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
|
<span className="ml-auto inline-flex items-center gap-1">
|
||||||
{t('exf.defaults')}
|
<Button variant="ghost" size="sm" className="h-6 text-[11px]"
|
||||||
</Button>
|
onClick={() => setSel(new Set(everyTag))}>
|
||||||
|
{t('exf.selectAll')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 text-[11px]"
|
||||||
|
disabled={sel.size === 0}
|
||||||
|
onClick={() => setSel(new Set())}>
|
||||||
|
{t('exf.selectNone')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
|
||||||
|
{t('exf.defaults')}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
|
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
|
||||||
|
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
|
||||||
|
import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase';
|
||||||
|
|
||||||
|
// FT Map — the live decode feed as geography: every station decoded in the
|
||||||
|
// last half hour, an arc from the operator's own square to theirs, coloured by
|
||||||
|
// band the way PSK Reporter taught everyone to read it. Wholly display: the
|
||||||
|
// decode list is the same one the FT decodes tab shows, and a station with no
|
||||||
|
// grid (never sent one in a CQ) simply cannot be placed and is not drawn.
|
||||||
|
//
|
||||||
|
// Performance is a design constraint, not an afterthought: the panel only
|
||||||
|
// exists while its tab is active (the parent unmounts it otherwise), the map
|
||||||
|
// renders with canvas (one <canvas>, not one DOM node per arc), the arcs are
|
||||||
|
// capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8,
|
||||||
|
// not per frame.
|
||||||
|
|
||||||
|
// The map is handed the SAME decode objects the list works from — it only reads
|
||||||
|
// a few of the fields. The rest travel with them so a click here can answer the
|
||||||
|
// station exactly as a double-click in the list does.
|
||||||
|
export type FTMapDecode = {
|
||||||
|
call: string;
|
||||||
|
grid?: string;
|
||||||
|
band?: string;
|
||||||
|
snr: number;
|
||||||
|
at: string;
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The band palette every PSK Reporter user already knows, near enough.
|
||||||
|
const BAND_COLOURS: Record<string, string> = {
|
||||||
|
'160m': '#7f7f7f', '80m': '#e550e5', '60m': '#00008b', '40m': '#5555ff',
|
||||||
|
'30m': '#62d962', '20m': '#f2c40c', '17m': '#f2f261', '15m': '#cca166',
|
||||||
|
'12m': '#b22222', '10m': '#ff69b4', '6m': '#ff0000', '4m': '#cc0044',
|
||||||
|
'2m': '#ff1493', '70cm': '#999900',
|
||||||
|
};
|
||||||
|
const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af';
|
||||||
|
|
||||||
|
const MAX_ARCS = 300;
|
||||||
|
const MAX_AGE_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||||
|
decodes: FTMapDecode[];
|
||||||
|
myGrid: string;
|
||||||
|
// One click takes the station, two answer it — the list's own gestures.
|
||||||
|
onSelect?: (d: FTMapDecode) => void;
|
||||||
|
onCall?: (d: FTMapDecode) => void;
|
||||||
|
}) {
|
||||||
|
// Held in refs so the redraw below does not have to list them as dependencies
|
||||||
|
// and rebuild every arc whenever the parent re-renders.
|
||||||
|
const selectRef = useRef(onSelect);
|
||||||
|
const callRef = useRef(onCall);
|
||||||
|
useEffect(() => { selectRef.current = onSelect; callRef.current = onCall; }, [onSelect, onCall]);
|
||||||
|
// Distinguishing the two gestures is ours to do: Leaflet fires click before
|
||||||
|
// dblclick and leaves the telling apart to the handler.
|
||||||
|
const clickTimer = useRef<number | undefined>(undefined);
|
||||||
|
useEffect(() => () => window.clearTimeout(clickTimer.current), []);
|
||||||
|
// Where this map was left. Panning and zooming it is the operator saying which
|
||||||
|
// part of the world they are working; throwing that away on every tab switch
|
||||||
|
// made it something to set up again rather than something to glance at.
|
||||||
|
const saved = useRef(loadMapView(MAP_VIEW_FT));
|
||||||
|
const { t } = useI18n();
|
||||||
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||||
|
const baseRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const [basemap, setBasemap] = useState<BasemapKey>(() =>
|
||||||
|
(localStorage.getItem('opslog.ftmapBase') as BasemapKey) || 'satellite');
|
||||||
|
|
||||||
|
// The map itself, once.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!divRef.current || mapRef.current) return;
|
||||||
|
// ONE world: noWrap tiles inside hard bounds, no side-by-side copies —
|
||||||
|
// and the space beyond the edge is the theme's own surface (style.css).
|
||||||
|
const m = L.map(divRef.current, {
|
||||||
|
zoomControl: true, attributionControl: true,
|
||||||
|
// No maxBounds: with the world smaller than the window the clamp
|
||||||
|
// dragged every zoom into a corner. noWrap tiles alone keep one world.
|
||||||
|
worldCopyJump: false, preferCanvas: true,
|
||||||
|
center: saved.current ? [saved.current.lat, saved.current.lon] : [25, 0],
|
||||||
|
zoom: saved.current ? saved.current.zoom : 2,
|
||||||
|
minZoom: 2,
|
||||||
|
});
|
||||||
|
// Every move, not just the deliberate ones: a zoom is as much a choice as a
|
||||||
|
// pan, and there is no moment afterwards at which to ask.
|
||||||
|
m.on('moveend', () => {
|
||||||
|
const c = m.getCenter();
|
||||||
|
saveMapView(MAP_VIEW_FT, c.lat, c.lng, m.getZoom());
|
||||||
|
});
|
||||||
|
mapRef.current = m;
|
||||||
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
|
// Leaflet measures its container ONCE, when the map is created, and then
|
||||||
|
// draws tiles for that size for ever. This panel is mounted the moment its
|
||||||
|
// tab is selected — before the flex layout has settled — and the window can
|
||||||
|
// be resized under it, so the stale measurement showed as a strip of dead
|
||||||
|
// space along the bottom where tiles were never asked for. The observer
|
||||||
|
// hands it the real size whenever the box changes.
|
||||||
|
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
|
||||||
|
ro.observe(divRef.current);
|
||||||
|
// Once more after the first paint: the first observation can arrive while
|
||||||
|
// the panel is still zero-height, and no further resize follows a layout
|
||||||
|
// that settles by itself.
|
||||||
|
const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(settle);
|
||||||
|
ro.disconnect();
|
||||||
|
m.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
layerRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Basemap follows the picker.
|
||||||
|
useEffect(() => {
|
||||||
|
const m = mapRef.current;
|
||||||
|
if (!m) return;
|
||||||
|
baseRef.current?.remove(); labelsRef.current?.remove();
|
||||||
|
const bm = BASEMAPS[basemap];
|
||||||
|
const opts: L.TileLayerOptions = {
|
||||||
|
maxNativeZoom: bm.maxNativeZoom,
|
||||||
|
noWrap: true,
|
||||||
|
bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
|
||||||
|
};
|
||||||
|
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||||
|
saveMapBase(MAP_BASE_FT, basemap);
|
||||||
|
}, [basemap]);
|
||||||
|
|
||||||
|
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
||||||
|
// on top; opacity falls with age so the map reads as "now" with a memory.
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = layerRef.current;
|
||||||
|
if (!layer) return;
|
||||||
|
layer.clearLayers();
|
||||||
|
const from = gridToLatLon(myGrid);
|
||||||
|
if (!from) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const placed = decodes
|
||||||
|
.filter((d) => d.grid && Date.parse(d.at) > now - MAX_AGE_MS)
|
||||||
|
.slice(-MAX_ARCS);
|
||||||
|
// One line per CALL (its freshest sighting): the same CQer decoded thirty
|
||||||
|
// times in ten minutes is one path on the air, not thirty strokes of it.
|
||||||
|
const byCall = new Map<string, FTMapDecode>();
|
||||||
|
for (const d of placed) byCall.set(d.call.toUpperCase(), d);
|
||||||
|
L.circleMarker([from.lat, from.lon], {
|
||||||
|
radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
|
||||||
|
}).addTo(layer);
|
||||||
|
for (const d of byCall.values()) {
|
||||||
|
const to = gridToLatLon(d.grid!);
|
||||||
|
if (!to) continue;
|
||||||
|
const age = now - Date.parse(d.at);
|
||||||
|
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
|
||||||
|
const colour = bandColour(d.band);
|
||||||
|
// Cut at the antimeridian: this map shows ONE world, so a path running
|
||||||
|
// past ±180 has to leave one edge and come back at the other. Without it
|
||||||
|
// every arc out of VK or ZL was drawn into the blank space off the side
|
||||||
|
// of the map, its far end sitting alone on the opposite coast.
|
||||||
|
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
|
||||||
|
L.polyline(pts as L.LatLngExpression[][], {
|
||||||
|
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
||||||
|
}).addTo(layer);
|
||||||
|
const label = `${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`;
|
||||||
|
const mk = L.circleMarker([to.lat, to.lon], {
|
||||||
|
// A three-pixel dot is a fine mark and a poor target, so the visible
|
||||||
|
// radius stays and an invisible one three times the size takes the
|
||||||
|
// clicks.
|
||||||
|
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
|
||||||
|
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
||||||
|
// The tooltip goes on the HIT circle too, and it is the one that matters:
|
||||||
|
// being on top, it takes the hover as well as the click, and binding it
|
||||||
|
// only to the dot underneath left the map silent from the moment the dots
|
||||||
|
// became clickable — the callsign and report an operator reads by pointing
|
||||||
|
// at a station had simply gone.
|
||||||
|
const hit = L.circleMarker([to.lat, to.lon], {
|
||||||
|
radius: 9, opacity: 0, fillOpacity: 0, interactive: true,
|
||||||
|
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
||||||
|
for (const target of [mk, hit]) {
|
||||||
|
target.on('click', (e) => {
|
||||||
|
// Not to the map: a click on a station is not a click on the water.
|
||||||
|
L.DomEvent.stopPropagation(e as unknown as Event);
|
||||||
|
window.clearTimeout(clickTimer.current);
|
||||||
|
clickTimer.current = window.setTimeout(() => selectRef.current?.(d), 250);
|
||||||
|
});
|
||||||
|
target.on('dblclick', (e) => {
|
||||||
|
// stop(), not stopPropagation(): the map zooms on a double click, and
|
||||||
|
// answering a station is not a request to zoom in on it.
|
||||||
|
L.DomEvent.stop(e as unknown as Event);
|
||||||
|
window.clearTimeout(clickTimer.current);
|
||||||
|
callRef.current?.(d);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [decodes, myGrid]);
|
||||||
|
|
||||||
|
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
||||||
|
return (
|
||||||
|
// isolate: Leaflet stacks its panes and controls up to z-index 1000, which
|
||||||
|
// beat the app menus and the Settings dialog. A stacking context of our own
|
||||||
|
// keeps all of it inside this panel.
|
||||||
|
<div className="relative isolate z-0 h-full w-full min-h-0">
|
||||||
|
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
||||||
|
{/* Basemap picker, MainMap's own vocabulary. */}
|
||||||
|
<div className="absolute top-2 left-12 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
||||||
|
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||||
|
<button key={k} type="button" onClick={() => setBasemap(k)}
|
||||||
|
className={cn('px-2 py-0.5 rounded text-[11px]',
|
||||||
|
basemap === k ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
|
{BASEMAPS[k].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Band legend — only the bands actually on screen. */}
|
||||||
|
{bands.length > 0 && (
|
||||||
|
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
|
||||||
|
{bands.map((b) => (
|
||||||
|
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
|
||||||
|
<span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />
|
||||||
|
{b.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!gridToLatLon(myGrid) && (
|
||||||
|
<div className="absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none">
|
||||||
|
<span className="rounded-md bg-background/90 border border-border px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{t('ftmap.noGrid')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
@@ -33,6 +33,8 @@ type FieldType = 'text' | 'number' | 'date' | 'adifdate';
|
|||||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||||
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||||
|
// When the record entered the logbook — the way to isolate what an import added.
|
||||||
|
{ value: 'created_at', label: 'fltb.fCreated', type: 'date' },
|
||||||
{ value: 'qso_date_off', label: 'fltb.fEndDate', type: 'date' },
|
{ value: 'qso_date_off', label: 'fltb.fEndDate', type: 'date' },
|
||||||
{ value: 'band', label: 'fltb.fBand', type: 'text' },
|
{ value: 'band', label: 'fltb.fBand', type: 'text' },
|
||||||
{ value: 'band_rx', label: 'fltb.fRxBand', type: 'text' },
|
{ value: 'band_rx', label: 'fltb.fRxBand', type: 'text' },
|
||||||
@@ -68,6 +70,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
||||||
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
|
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
||||||
|
{ value: 'qsl_sent_via', label: 'fltb.fQslSentVia', type: 'text' },
|
||||||
|
{ value: 'qsl_rcvd_via', label: 'fltb.fQslRcvdVia', type: 'text' },
|
||||||
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
||||||
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
|
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
|
||||||
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
||||||
@@ -82,8 +86,18 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
||||||
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
||||||
|
{ value: 'clublog_qso_download_status', label: 'fltb.fClublogRcvd', type: 'text' },
|
||||||
|
{ value: 'clublog_qso_download_date', label: 'fltb.fClublogRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
||||||
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
||||||
|
// HAMLOG.online: no promoted column, filtered through extras_json (see
|
||||||
|
// qso.filterableExtras).
|
||||||
|
{ value: 'hamlog_sent', label: 'fltb.fHamlogSent', type: 'text' },
|
||||||
|
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
|
||||||
|
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
|
||||||
|
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' },
|
||||||
|
{ value: 'hamqth_sent', label: 'fltb.fHamqthSent', type: 'text' },
|
||||||
|
{ value: 'hamqth_sent_date', label: 'fltb.fHamqthSentDate', type: 'adifdate' },
|
||||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||||
@@ -190,6 +204,17 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
|
|
||||||
function apply() { onApply(buildFilter()); }
|
function apply() { onApply(buildFilter()); }
|
||||||
|
|
||||||
|
// Same silence as the alert rules: the preset landed in the list, but the list
|
||||||
|
// is not where the operator is looking when they press Save.
|
||||||
|
const [savedMsg, setSavedMsg] = useState('');
|
||||||
|
const savedTimer = useRef(0);
|
||||||
|
function flashSaved(text: string) {
|
||||||
|
setSavedMsg(text);
|
||||||
|
window.clearTimeout(savedTimer.current);
|
||||||
|
savedTimer.current = window.setTimeout(() => setSavedMsg(''), 3000);
|
||||||
|
}
|
||||||
|
useEffect(() => () => window.clearTimeout(savedTimer.current), []);
|
||||||
|
|
||||||
function saveCurrentPreset() {
|
function saveCurrentPreset() {
|
||||||
const name = presetName.trim();
|
const name = presetName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
@@ -197,6 +222,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
savePresets(next);
|
savePresets(next);
|
||||||
setPresets(next);
|
setPresets(next);
|
||||||
setPresetName('');
|
setPresetName('');
|
||||||
|
flashSaved(t('fltb.presetSaved', { name }));
|
||||||
}
|
}
|
||||||
function loadPreset(name: string) {
|
function loadPreset(name: string) {
|
||||||
const f = presets[name];
|
const f = presets[name];
|
||||||
@@ -333,6 +359,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
<Button variant="outline" size="sm" className="h-8" disabled={!presetName.trim()} onClick={saveCurrentPreset}>
|
<Button variant="outline" size="sm" className="h-8" disabled={!presetName.trim()} onClick={saveCurrentPreset}>
|
||||||
<Save className="size-3.5 mr-1" /> {t('fltb.savePreset')}
|
<Save className="size-3.5 mr-1" /> {t('fltb.savePreset')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{savedMsg && <span className="text-[11px] text-success truncate">{savedMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||||
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
||||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||||
|
GetFlexRSTChase, SaveFlexRSTChase, SetFlexRSTChaseEnabled,
|
||||||
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||||
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
|
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
|
||||||
@@ -304,6 +305,25 @@ function powerLevelLabel(pl?: string): string {
|
|||||||
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
||||||
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
// Split-pile-up chaser (see flexrstchase.go). Held here so the button can
|
||||||
|
// paint its state without polling the backend for it.
|
||||||
|
const [rstChase, setRstChase] = useState<{ enabled: boolean; markers: string; offset_hz: number; split_only: boolean }>(
|
||||||
|
{ enabled: false, markers: '599', offset_hz: 0, split_only: true });
|
||||||
|
const [rstChaseOffsetText, setRstChaseOffsetText] = useState('0');
|
||||||
|
// The marker text is edited as raw text and only parsed on blur: it is a
|
||||||
|
// comma-separated list, and normalising it on every keystroke would eat the
|
||||||
|
// comma the moment it is typed.
|
||||||
|
const [rstChaseMarkersText, setRstChaseMarkersText] = useState('599');
|
||||||
|
// The marker field is summoned by right-clicking the button, not parked on
|
||||||
|
// the row: it is set once to agree with SDC and then never touched, while the
|
||||||
|
// row it was sitting in is the busiest in the panel.
|
||||||
|
const [rstChaseEditing, setRstChaseEditing] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
GetFlexRSTChase().then((c: any) => {
|
||||||
|
if (!c) return;
|
||||||
|
setRstChase(c); setRstChaseOffsetText(String(c.offset_hz ?? 0)); setRstChaseMarkersText(String(c.markers ?? '599'));
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
const [st, setSt] = useState<FlexState>(ZERO);
|
const [st, setSt] = useState<FlexState>(ZERO);
|
||||||
// Extra/"advanced" DSP rows (WNB + the SmartSDR v4 NRL/NRS/NRF/ANFL/AI-FFT
|
// Extra/"advanced" DSP rows (WNB + the SmartSDR v4 NRL/NRS/NRF/ANFL/AI-FFT
|
||||||
// block) collapse behind a button so the RECEIVE card doesn't grow tall — only
|
// block) collapse behind a button so the RECEIVE card doesn't grow tall — only
|
||||||
@@ -652,6 +672,64 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
st.split ? 'bg-info text-info-foreground border-info shadow-[0_0_12px] shadow-info/50' : 'bg-card text-info border-info hover:bg-info-muted')}>
|
st.split ? 'bg-info text-info-foreground border-info shadow-[0_0_12px] shadow-info/50' : 'bg-card text-info border-info hover:bg-info-muted')}>
|
||||||
SPLIT
|
SPLIT
|
||||||
</button>
|
</button>
|
||||||
|
{/* The split chaser. On the panel and not in a settings page:
|
||||||
|
it is switched on when a DXpedition appears and off when it is
|
||||||
|
in the log, which is done mid-QSO with one hand. The offset
|
||||||
|
sits beside it because it is the one number retuned while
|
||||||
|
chasing — everything else about the feature is configured once. */}
|
||||||
|
{/* CW ONLY. The marker comes from a skimmer decoding a CW report,
|
||||||
|
so on SSB or FT8 there is nothing to chase — and a switch that
|
||||||
|
cannot fire is an invitation to wonder why it does nothing. */}
|
||||||
|
{isCW && (<>
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
title={t('flxp.rstChaseHint', { m: rstChase.markers })}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setRstChaseEditing(true); }}
|
||||||
|
onClick={() => { const on = !rstChase.enabled; setRstChase({ ...rstChase, enabled: on }); SetFlexRSTChaseEnabled(on).catch(() => {}); }}
|
||||||
|
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
rstChase.enabled ? 'bg-success text-success-foreground border-success shadow-[0_0_12px] shadow-success/50' : 'bg-card text-success border-success hover:bg-success-muted')}>
|
||||||
|
{rstChase.markers.split(',')[0].trim() || '599'}
|
||||||
|
</button>
|
||||||
|
{/* The marker is whatever SDC was told to write — "599", "5NN", or
|
||||||
|
several. It has to agree with the skimmer or nothing ever fires,
|
||||||
|
so it is edited here rather than in a settings page — but only
|
||||||
|
when asked for, since it is set once and then left alone. */}
|
||||||
|
{rstChaseEditing && (
|
||||||
|
<input type="text" autoFocus value={rstChaseMarkersText}
|
||||||
|
onChange={(e) => setRstChaseMarkersText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||||
|
if (e.key === 'Escape') { setRstChaseMarkersText(rstChase.markers); setRstChaseEditing(false); }
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const v = rstChaseMarkersText.trim() || '599';
|
||||||
|
setRstChaseMarkersText(v);
|
||||||
|
setRstChaseEditing(false);
|
||||||
|
if (v === rstChase.markers) return;
|
||||||
|
const next = { ...rstChase, markers: v };
|
||||||
|
setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {});
|
||||||
|
}}
|
||||||
|
title={t('flxp.rstChaseMarkerHint')}
|
||||||
|
className="w-16 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono uppercase" />
|
||||||
|
)}
|
||||||
|
{rstChase.enabled && (
|
||||||
|
<label className="flex items-center gap-1 text-[11px] text-muted-foreground whitespace-nowrap ml-2">
|
||||||
|
{t('flxp.rstChaseOffset')}
|
||||||
|
<input type="number" step={10} value={rstChaseOffsetText}
|
||||||
|
onChange={(e) => setRstChaseOffsetText(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
// Kept as text while typing: normalising every keystroke
|
||||||
|
// makes a minus sign impossible to enter.
|
||||||
|
const n = Math.round(Number(rstChaseOffsetText));
|
||||||
|
const v = Number.isFinite(n) ? n : 0;
|
||||||
|
setRstChaseOffsetText(String(v));
|
||||||
|
const next = { ...rstChase, offset_hz: v };
|
||||||
|
setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {});
|
||||||
|
}}
|
||||||
|
className="w-16 h-6 rounded border border-input bg-background px-1.5 text-[11px] font-mono" />
|
||||||
|
Hz
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</>)}
|
||||||
{st.split && !!st.tx_freq_hz && (
|
{st.split && !!st.tx_freq_hz && (
|
||||||
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
|
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
|
||||||
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
|
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { Loader2, RefreshCw } from 'lucide-react';
|
||||||
|
import { GridSquares } from '../../wailsjs/go/main/App';
|
||||||
|
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { BASEMAPS, addBasemap, type BasemapKey } from '@/components/MainMap';
|
||||||
|
import { loadMapBase, saveMapBase, MAP_BASE_GRIDS, MAP_BASE_WORLD } from '@/lib/mapBase';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { loadMapView, saveMapView, MAP_VIEW_GRIDS } from '@/lib/mapView';
|
||||||
|
|
||||||
|
// GridSquareMap — every Maidenhead square in the log, drawn on a world map.
|
||||||
|
//
|
||||||
|
// A square, not a marker. The question this answers is "where have I been
|
||||||
|
// heard", and the shape of the answer is an area: a wall of squares across the
|
||||||
|
// Atlantic and a bare Pacific says something about an antenna that a scatter of
|
||||||
|
// pins does not. Confirmed and merely worked are two fills of the same hue —
|
||||||
|
// the distinction is a STATE of one thing, not two categories, so it must not
|
||||||
|
// be two unrelated colours.
|
||||||
|
//
|
||||||
|
// 4 characters, never 6: at world zoom a 6-character square is sub-pixel, and
|
||||||
|
// aggregating to the big square is also how the count means "contacts in this
|
||||||
|
// square" rather than "contacts at this exact spot".
|
||||||
|
|
||||||
|
type Square = { grid: string; count: number; confirmed: boolean; band?: string; mode?: string };
|
||||||
|
|
||||||
|
// The SAME basemaps the Main-tab world map offers, imported rather than copied:
|
||||||
|
// two lists of tile servers is how one map ends up on a provider the other has
|
||||||
|
// already been blocked by. This map opened on a hardcoded dark Carto, which is
|
||||||
|
// the least legible of the four under translucent squares.
|
||||||
|
|
||||||
|
// Leaflet geometry is painted onto a CANVAS, and canvas takes a colour, not a
|
||||||
|
// stylesheet: "var(--success)" handed to fillStyle is simply invalid and the
|
||||||
|
// shape is silently not drawn — a map with a correct square count and nothing
|
||||||
|
// on it. So the token is RESOLVED to its literal value here, once per theme,
|
||||||
|
// which is also why every other map in this app passes hex.
|
||||||
|
// The Earth, in the projection's own terms. Latitude stops at ±85.0511 because
|
||||||
|
// that is where Web Mercator does.
|
||||||
|
const WORLD_BOUNDS = L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180));
|
||||||
|
|
||||||
|
function cssColour(token: string, fallback: string): string {
|
||||||
|
try {
|
||||||
|
const v = getComputedStyle(document.documentElement).getPropertyValue(token).trim();
|
||||||
|
return v || fallback;
|
||||||
|
} catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode scope. The names come straight from the backend's own classes ("ALL",
|
||||||
|
// "PHONE", "CW", "DIGI") plus FTX, which is narrower than digital and usually
|
||||||
|
// the honest one beside an FTx panel — a square worked on RTTY in a contest is
|
||||||
|
// not a square worked on FT8.
|
||||||
|
const SCOPES = [
|
||||||
|
{ key: 'ALL', label: 'gsm.all' },
|
||||||
|
{ key: 'PHONE', label: 'gsm.phone' },
|
||||||
|
{ key: 'CW', label: 'gsm.cw' },
|
||||||
|
{ key: 'DIGI', label: 'gsm.digital' },
|
||||||
|
{ key: 'FTX', label: 'gsm.ftx' },
|
||||||
|
] as const;
|
||||||
|
type ScopeKey = typeof SCOPES[number]['key'];
|
||||||
|
|
||||||
|
const SCOPE_KEY = 'opslog.gridMapScope';
|
||||||
|
// Chosen fill colours. Empty means "follow the theme", which is the default and
|
||||||
|
// stays the default: the tokens already track the four themes, and freezing a
|
||||||
|
// hex at first run would leave a dark-theme map painted in the light palette.
|
||||||
|
const COL_CONFIRMED_KEY = 'opslog.gridMapColorConfirmed';
|
||||||
|
const COL_WORKED_KEY = 'opslog.gridMapColorWorked';
|
||||||
|
|
||||||
|
// A colour input only accepts #rrggbb. The theme tokens ARE plain hex today, so
|
||||||
|
// this normally just passes them through — but a token that ever becomes oklch()
|
||||||
|
// or a named colour would silently drive the swatch to black, and a fallback is
|
||||||
|
// cheaper than that debugging session.
|
||||||
|
function asHex(v: string, fallback: string): string {
|
||||||
|
return /^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridSquareMap({ myGrid, className }: { myGrid?: string; className?: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||||
|
const [squares, setSquares] = useState<Square[] | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [scope, setScope] = useState<ScopeKey>(
|
||||||
|
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
|
||||||
|
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
|
||||||
|
|
||||||
|
// This map's own imagery. It shared the world map's key until they were
|
||||||
|
// separated, so a choice made back then is inherited rather than reset.
|
||||||
|
const [basemap, setBasemap] = useState<BasemapKey>(() => loadMapBase(MAP_BASE_GRIDS, 'light', MAP_BASE_WORLD));
|
||||||
|
const [confColour, setConfColour] = useState(() => localStorage.getItem(COL_CONFIRMED_KEY) ?? '');
|
||||||
|
const [workedColour, setWorkedColour] = useState(() => localStorage.getItem(COL_WORKED_KEY) ?? '');
|
||||||
|
// Repaint the squares when the THEME changes, not the basemap: the fills come
|
||||||
|
// from theme tokens resolved at draw time, so a theme switch leaves them on
|
||||||
|
// the old palette until something forces a redraw.
|
||||||
|
const [themeTick, setThemeTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const obs = new MutationObserver(() => setThemeTick((n) => n + 1));
|
||||||
|
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
|
return () => obs.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = async (sc: ScopeKey = scope) => {
|
||||||
|
setBusy(true); setErr('');
|
||||||
|
try {
|
||||||
|
const r = (await GridSquares(sc)) as any;
|
||||||
|
setSquares((Array.isArray(r) ? r : []) as Square[]);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
setSquares([]);
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
useEffect(() => { void load(scope); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope]);
|
||||||
|
|
||||||
|
// One-time map creation. preferCanvas: a busy digital log is a few thousand
|
||||||
|
// rectangles, and as SVG that is a few thousand DOM nodes to lay out on every
|
||||||
|
// pan.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hostRef.current || mapRef.current) return;
|
||||||
|
const m = L.map(hostRef.current, {
|
||||||
|
preferCanvas: true,
|
||||||
|
zoomControl: true,
|
||||||
|
attributionControl: true,
|
||||||
|
// ONE world, not a row of them. worldCopyJump exists to make a path look
|
||||||
|
// continuous across the date line; here it only meant the same continent
|
||||||
|
// appeared two or three times, each carrying the same squares.
|
||||||
|
worldCopyJump: false,
|
||||||
|
// Zoom 0 is the whole planet in 256 pixels — the level at which Greenland
|
||||||
|
// and Antarctica are both on screen. The old floor of 1 made that
|
||||||
|
// impossible on any window wider than it is tall: one step out was already
|
||||||
|
// too far in.
|
||||||
|
minZoom: 0,
|
||||||
|
|
||||||
|
}).setView([20, 0], 2);
|
||||||
|
// The whole world, once, whatever the window is shaped like — computed by
|
||||||
|
// Leaflet from the container rather than guessed with a zoom number. Unless
|
||||||
|
// the operator has already chosen a view: theirs is the answer, and fitting
|
||||||
|
// the world over it would undo the choice on every tab switch.
|
||||||
|
const saved = loadMapView(MAP_VIEW_GRIDS);
|
||||||
|
if (saved) m.setView([saved.lat, saved.lon], saved.zoom);
|
||||||
|
else m.fitWorld({ animate: false });
|
||||||
|
m.on('moveend', () => {
|
||||||
|
const c = m.getCenter();
|
||||||
|
saveMapView(MAP_VIEW_GRIDS, c.lat, c.lng, m.getZoom());
|
||||||
|
});
|
||||||
|
mapRef.current = m;
|
||||||
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
|
// Leaflet measures its container ONCE, when the map is created, and never
|
||||||
|
// looks again. Here that measurement happens while the panel is still
|
||||||
|
// laying out — so the map kept the height it had at that instant and the
|
||||||
|
// rest of the panel stayed blank underneath it, whatever the window size.
|
||||||
|
// Watching the host is the only fix that also survives a window resize, a
|
||||||
|
// split-pane drag and the tab being shown for the first time.
|
||||||
|
// fitWorld above ran against a container that may still have had no size —
|
||||||
|
// the tab is mounted hidden. Fit ONCE more the first time it really has one,
|
||||||
|
// and never again: after that the view belongs to the operator.
|
||||||
|
let fitted = !!saved; // a remembered view is already the right size
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
m.invalidateSize({ animate: false });
|
||||||
|
const el = hostRef.current;
|
||||||
|
if (!fitted && el && el.clientWidth > 0 && el.clientHeight > 0) {
|
||||||
|
fitted = true;
|
||||||
|
m.fitWorld({ animate: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(hostRef.current);
|
||||||
|
return () => { ro.disconnect(); m.remove(); mapRef.current = null; layerRef.current = null; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The chosen basemap, shared with the Main-tab map so picking one there and
|
||||||
|
// finding another here cannot happen.
|
||||||
|
const baseRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const m = mapRef.current;
|
||||||
|
if (!m) return;
|
||||||
|
// noWrap stops the world REPEATING; bounds stops Leaflet asking for the
|
||||||
|
// empty columns either side of it. What is left beside the planet is then
|
||||||
|
// the panel's own background rather than a wall of grey apologies.
|
||||||
|
addBasemap(m, basemap, baseRef, labelsRef, { noWrap: true, bounds: WORLD_BOUNDS });
|
||||||
|
baseRef.current?.bringToBack();
|
||||||
|
}, [basemap]);
|
||||||
|
|
||||||
|
// Redraw the squares.
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = layerRef.current;
|
||||||
|
if (!layer) return;
|
||||||
|
layer.clearLayers();
|
||||||
|
// Resolved once for the whole redraw, not per square: getComputedStyle
|
||||||
|
// forces a style flush, and doing that a thousand times is a visible stall.
|
||||||
|
const confirmedColour = confColour || cssColour('--success', '#16a34a');
|
||||||
|
const workedFill = workedColour || cssColour('--chart-1', '#2a78d6');
|
||||||
|
const meColour = cssColour('--warning', '#f59e0b');
|
||||||
|
for (const sq of squares ?? []) {
|
||||||
|
const b = gridSquareBounds(sq.grid);
|
||||||
|
if (!b) continue;
|
||||||
|
// One hue, two states. Confirmed is the solid, saturated one; worked is
|
||||||
|
// the same colour held back — so the eye reads "more" and "less" of the
|
||||||
|
// same thing rather than two unrelated facts.
|
||||||
|
const colour = sq.confirmed ? confirmedColour : workedFill;
|
||||||
|
L.rectangle([[b.south, b.west], [b.north, b.east]], {
|
||||||
|
color: colour,
|
||||||
|
weight: 0.5,
|
||||||
|
opacity: sq.confirmed ? 0.9 : 0.5,
|
||||||
|
fillColor: colour,
|
||||||
|
fillOpacity: sq.confirmed ? 0.55 : 0.22,
|
||||||
|
})
|
||||||
|
.bindTooltip(
|
||||||
|
`${sq.grid} — ${sq.count} QSO${sq.count > 1 ? 's' : ''}` +
|
||||||
|
`${sq.band ? ` · ${sq.band}` : ''}${sq.mode ? ` ${sq.mode}` : ''}` +
|
||||||
|
`${sq.confirmed ? ` · ${t('gsm.confirmed')}` : ''}`,
|
||||||
|
{ sticky: true },
|
||||||
|
)
|
||||||
|
.addTo(layer);
|
||||||
|
}
|
||||||
|
// The operator's own square, so the pattern has an origin to be read from.
|
||||||
|
const me = myGrid ? gridToLatLon(myGrid) : null;
|
||||||
|
if (me) {
|
||||||
|
L.circleMarker([me.lat, me.lon], {
|
||||||
|
radius: 4, color: meColour, weight: 2,
|
||||||
|
fillColor: meColour, fillOpacity: 1,
|
||||||
|
}).bindTooltip(myGrid!.toUpperCase(), { sticky: true }).addTo(layer);
|
||||||
|
}
|
||||||
|
}, [squares, myGrid, t, themeTick, confColour, workedColour]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const list = squares ?? [];
|
||||||
|
return { total: list.length, confirmed: list.filter((s) => s.confirmed).length };
|
||||||
|
}, [squares]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// isolate is load-bearing, not tidiness. Leaflet puts its panes at z-index
|
||||||
|
// 400 and its zoom control at 1000, in the PAGE's stacking context — and the
|
||||||
|
// modals here are z-50. Without a stacking context of its own the map floats
|
||||||
|
// over Preferences and hides the Save and Close buttons. isolation:isolate
|
||||||
|
// confines every z-index Leaflet sets to this element.
|
||||||
|
<section className={cn('isolate relative flex flex-col min-h-0 rounded-lg border border-border bg-card overflow-hidden', className)}>
|
||||||
|
<header className="flex items-center gap-2 px-2.5 py-1.5 border-b border-border bg-muted/40 shrink-0 flex-wrap">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t('gsm.title')}
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{SCOPES.map((s, i) => (
|
||||||
|
<button key={s.key} type="button"
|
||||||
|
onClick={() => { setScope(s.key); try { localStorage.setItem(SCOPE_KEY, s.key); } catch { /* quota */ } }}
|
||||||
|
className={cn('px-1.5 h-6 text-[11px] whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||||
|
scope === s.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground')}>
|
||||||
|
{t(s.label)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{t('gsm.count', { n: stats.total, c: stats.confirmed })}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={basemap}
|
||||||
|
onChange={(e) => { const v = e.target.value as BasemapKey; setBasemap(v); saveMapBase(MAP_BASE_GRIDS, v); }}
|
||||||
|
title={t('gsm.basemap')}
|
||||||
|
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
|
||||||
|
>
|
||||||
|
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||||
|
<option key={k} value={k}>{BASEMAPS[k].label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{/* Beside the basemap, because they answer the same question: what this
|
||||||
|
map looks like. Written straight through — there is no Save here. */}
|
||||||
|
<label className="inline-flex items-center gap-1 text-[11px] text-muted-foreground" title={t('gsm.colConfirmed')}>
|
||||||
|
<input type="color" className="size-5 rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||||
|
value={asHex(confColour || cssColour('--success', '#16a34a'), '#16a34a')}
|
||||||
|
onChange={(e) => { setConfColour(e.target.value); writeUiPref(COL_CONFIRMED_KEY, e.target.value); }} />
|
||||||
|
{t('gsm.confirmed')}
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-1 text-[11px] text-muted-foreground" title={t('gsm.colWorked')}>
|
||||||
|
<input type="color" className="size-5 rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||||
|
value={asHex(workedColour || cssColour('--chart-1', '#2a78d6'), '#2a78d6')}
|
||||||
|
onChange={(e) => { setWorkedColour(e.target.value); writeUiPref(COL_WORKED_KEY, e.target.value); }} />
|
||||||
|
{t('gsm.worked')}
|
||||||
|
</label>
|
||||||
|
{(confColour || workedColour) && (
|
||||||
|
<button type="button" title={t('gsm.colReset')}
|
||||||
|
onClick={() => {
|
||||||
|
setConfColour(''); setWorkedColour('');
|
||||||
|
writeUiPref(COL_CONFIRMED_KEY, ''); writeUiPref(COL_WORKED_KEY, '');
|
||||||
|
}}
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground px-1">↺</button>
|
||||||
|
)}
|
||||||
|
<span className="flex-1" />
|
||||||
|
<button type="button" onClick={() => void load()} disabled={busy} title={t('gsm.refresh')}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
|
||||||
|
{busy ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{err && <p className="px-2.5 py-1 text-[11px] text-destructive shrink-0">{err}</p>}
|
||||||
|
{/* The map host must have a real height or Leaflet renders nothing at all
|
||||||
|
— flex-1 + min-h-0, never a percentage. */}
|
||||||
|
{/* The ground beside the planet is the panel's own background: Leaflet
|
||||||
|
paints its container, and left to its default that surround was a
|
||||||
|
bright white slab against a dark theme. */}
|
||||||
|
<div ref={hostRef} className="flex-1 min-h-0 bg-card" />
|
||||||
|
<footer className="flex items-center gap-3 px-2.5 py-1 border-t border-border bg-muted/30 shrink-0 text-[10px] text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="inline-block size-2.5 rounded-[2px]"
|
||||||
|
style={{ background: confColour || 'var(--success)', opacity: 0.75 }} />
|
||||||
|
{t('gsm.confirmed')}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="inline-block size-2.5 rounded-[2px]"
|
||||||
|
style={{ background: workedColour || 'var(--chart-1)', opacity: 0.35 }} />
|
||||||
|
{t('gsm.worked')}
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,26 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power } from 'lucide-react';
|
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power, Volume2, VolumeX } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetIcomState, IcomRefresh,
|
GetIcomState, IcomRefresh,
|
||||||
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||||||
IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||||||
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
|
AudioMonitorActive, AudioStartMonitor, AudioStopMonitor,
|
||||||
IcomSetScope, IcomScopeData, IcomSetScopeMode, IcomSetScopeEdges, GetCATState, SetCATFrequency, SetCATMode,
|
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetATU, IcomConsolePTT,
|
||||||
|
GetCATState, SetCATFrequency, SetCATMode,
|
||||||
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
||||||
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
||||||
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
||||||
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
|
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower, IcomRecallBand,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { sMeterRST } from '@/lib/rst';
|
import { sMeterRST } from '@/lib/rst';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import { ShiftRow } from '@/components/ShiftRow';
|
||||||
|
|
||||||
type IcomState = {
|
type IcomState = {
|
||||||
available: boolean; model?: string; mode?: string;
|
available: boolean; model?: string; mode?: string;
|
||||||
transmitting: boolean; split: boolean;
|
transmitting: boolean; split: boolean; sub_hz?: number; atu_on?: boolean;
|
||||||
s_meter: number; power_meter: number; swr_meter: number;
|
s_meter: number; power_meter: number; swr_meter: number;
|
||||||
rf_power: number; mic_gain: number;
|
rf_power: number; mic_gain: number;
|
||||||
af_gain: number; rf_gain: number;
|
af_gain: number; rf_gain: number;
|
||||||
@@ -50,7 +53,13 @@ const ZERO: IcomState = {
|
|||||||
type Band = { l: string; hz: number };
|
type Band = { l: string; hz: number };
|
||||||
|
|
||||||
const HF_BANDS: Band[] = [
|
const HF_BANDS: Band[] = [
|
||||||
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
|
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 },
|
||||||
|
// 60 m: the middle of the IARU Region 1 allocation (5351.5-5366.5 kHz), which
|
||||||
|
// every 60 m-capable rig can display. Where the band is channelised (the US)
|
||||||
|
// the operator moves to their channel from here — the button is a way onto
|
||||||
|
// the band, not a claim about what may be transmitted on it.
|
||||||
|
{ l: '60', hz: 5_354_000 },
|
||||||
|
{ l: '40', hz: 7_100_000 },
|
||||||
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
||||||
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
||||||
];
|
];
|
||||||
@@ -59,8 +68,9 @@ const B2 = { l: '2', hz: 144_300_000 }; // SSB calling
|
|||||||
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
|
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
|
||||||
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
|
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
|
||||||
|
|
||||||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
// These frequencies are the FALLBACK: with the band stacking registers switched
|
||||||
// the plain SetFrequency command — no band-stacking codes needed.
|
// on the radio is asked where the operator last was instead, and one of these is
|
||||||
|
// only sent for a band or a model whose register cannot be read.
|
||||||
//
|
//
|
||||||
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
|
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
|
||||||
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
|
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
|
||||||
@@ -75,19 +85,47 @@ function bandsFor(model?: string): Band[] {
|
|||||||
return [...HF_BANDS, B6];
|
return [...HF_BANDS, B6];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
// Mode buttons for the console (like RS-BA1's row).
|
||||||
// SSB by frequency and the rig's data variant for digital modes.
|
//
|
||||||
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
|
// LSB and USB by NAME, not one "SSB" button that resolves by band: the band
|
||||||
|
// convention is right for a logged mode and useless when the operator means
|
||||||
|
// "put this radio in USB on 40 m", which the console could not express at all.
|
||||||
|
//
|
||||||
|
// PSK is native only on the 7610/7760/7851 class; every other rig NAKs 0x12, so
|
||||||
|
// there the button is dead furniture — see modesFor. Soundcard PSK31 rides on
|
||||||
|
// DATA, which every rig can do.
|
||||||
|
const MODES_BASE = ['LSB', 'USB', 'CW', 'RTTY', 'AM', 'FM', 'DATA'];
|
||||||
|
|
||||||
|
function hasNativePSK(model?: string): boolean {
|
||||||
|
const m = (model ?? '').toUpperCase();
|
||||||
|
return m.includes('7610') || m.includes('7760') || m.includes('7851') ||
|
||||||
|
m.includes('7800') || m.includes('7700');
|
||||||
|
}
|
||||||
|
|
||||||
|
function modesFor(model?: string): string[] {
|
||||||
|
if (!hasNativePSK(model)) return MODES_BASE;
|
||||||
|
return [...MODES_BASE.slice(0, 4), 'PSK', ...MODES_BASE.slice(4)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which radios actually have an antenna selector on the CI-V command (0x12).
|
||||||
|
// An IC-7300 has ONE socket: offering it ANT1/ANT2 was two buttons that could
|
||||||
|
// only ever disagree with the front panel.
|
||||||
|
function hasAntennaSelector(model?: string): boolean {
|
||||||
|
const m = (model ?? '').toUpperCase();
|
||||||
|
return m.includes('7610') || m.includes('7760') || m.includes('7851') ||
|
||||||
|
m.includes('7800') || m.includes('7700') || m.includes('9700');
|
||||||
|
}
|
||||||
|
|
||||||
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
||||||
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
||||||
// dB stepped attenuator; the IC-7300/705/7100 have a single 20 dB attenuator; the
|
// dB stepped attenuator, and the IC-7760 the same (confirmed on a real one); the
|
||||||
|
// IC-7300/705/7100 have a single 20 dB attenuator; the
|
||||||
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
|
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
|
||||||
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
|
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
|
||||||
function attOptions(model?: string): { v: string; l: string }[] {
|
function attOptions(model?: string): { v: string; l: string }[] {
|
||||||
const m = (model ?? '').toUpperCase();
|
const m = (model ?? '').toUpperCase();
|
||||||
const OFF = { v: '0', l: 'OFF' };
|
const OFF = { v: '0', l: 'OFF' };
|
||||||
if (/(7610|7700|7800|7850|7851)/.test(m)) {
|
if (/(7610|7700|7760|7800|7850|7851)/.test(m)) {
|
||||||
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||||
}
|
}
|
||||||
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
|
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
|
||||||
@@ -126,9 +164,50 @@ function fmtVFO(hz?: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// modeMatches marks a mode button active, folding the rig's USB/LSB into SSB.
|
// modeMatches marks a mode button active, folding the rig's USB/LSB into SSB.
|
||||||
function modeMatches(btn: string, cur?: string): boolean {
|
// icomWatts turns the backend's 0-100 meter percentage back into watts on the
|
||||||
|
// IC-7760's own meter face. The backend value is linear in the RAW meter byte
|
||||||
|
// (0-255 → 0-100), but Icom's calibration is not: raw 143 is half deflection
|
||||||
|
// and raw 213 is full scale. On a real 7760 a measured 100 W sits at half
|
||||||
|
// deflection of the 250 W face — the linear ×2.5 first tried showed 140 W for
|
||||||
|
// it. Below half scale watts run 0→100, above it 100→250.
|
||||||
|
// The anchors are MEASURED on the real radio, not derived: a known 50 W read
|
||||||
|
// raw ≈89 and a known 100 W read raw 143 (Icom's documented half-deflection),
|
||||||
|
// with raw 213 = full scale = 250 W. The face is not linear in watts at the
|
||||||
|
// bottom — a two-segment guess showed 50 W as 62 — so watts interpolate
|
||||||
|
// between the measured anchors, and a new measurement just adds a row.
|
||||||
|
// Third measured anchor (2026-08-30): a real 200 W read full deflection —
|
||||||
|
// raw 213 is 200 W on this rig, not the 250 the printed face suggests.
|
||||||
|
const ICOM_7760_PO: [number, number][] = [[0, 0], [89, 50], [143, 100], [213, 200]];
|
||||||
|
function icomWatts(pct: number): { w: number; defl: number } {
|
||||||
|
const raw = Math.max(0, pct * 2.55);
|
||||||
|
const defl = raw <= 143 ? (raw / 143) * 50 : Math.min(100, 50 + ((raw - 143) / 70) * 50);
|
||||||
|
let w = 200;
|
||||||
|
for (let i = 1; i < ICOM_7760_PO.length; i++) {
|
||||||
|
const [r0, w0] = ICOM_7760_PO[i - 1], [r1, w1] = ICOM_7760_PO[i];
|
||||||
|
if (raw <= r1) { w = w0 + ((raw - r0) / (r1 - r0)) * (w1 - w0); break; }
|
||||||
|
}
|
||||||
|
return { w: Math.round(w), defl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which sideband a bare "SSB" means at this frequency — the same convention the
|
||||||
|
// backend applies when it resolves the mode for the radio.
|
||||||
|
function sideForHz(hz?: number): string | null {
|
||||||
|
if (!hz || hz <= 0) return null;
|
||||||
|
return hz < 10_000_000 ? 'LSB' : 'USB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeMatches(btn: string, cur?: string, hz?: number): boolean {
|
||||||
if (!cur) return false;
|
if (!cur) return false;
|
||||||
if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB';
|
// A rig that reports the folded ADIF "SSB" still lights the side its
|
||||||
|
// frequency implies, so the row is never blank on a phone contact.
|
||||||
|
if (btn === 'USB' || btn === 'LSB') {
|
||||||
|
if (cur === btn) return true;
|
||||||
|
return cur === 'SSB' && btn === (sideForHz(hz) ?? '');
|
||||||
|
}
|
||||||
|
// The backend surfaces USB-D as the operator's digital default (FT8…), or as
|
||||||
|
// plain DATA — either way it is the DATA button that should light.
|
||||||
|
if (btn === 'DATA') return ['DATA', 'FT8', 'FT4', 'JS8', 'JT65', 'JT9', 'MFSK', 'OLIVIA'].includes(cur);
|
||||||
|
if (btn === 'PSK') return cur === 'PSK' || cur === 'PSK31';
|
||||||
return btn === cur;
|
return btn === cur;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,51 +318,25 @@ function Meter({ label, value, accent, scale, onClick, title }: { label: string;
|
|||||||
<div className="flex-1 h-2.5 rounded-full bg-muted/60 overflow-hidden">
|
<div className="flex-1 h-2.5 rounded-full bg-muted/60 overflow-hidden">
|
||||||
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
|
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="w-10 text-right text-[11px] font-mono tabular-nums text-muted-foreground">{scale ?? v}</span>
|
<span className="w-14 shrink-0 whitespace-nowrap text-right text-[11px] font-mono tabular-nums text-muted-foreground">{scale ?? v}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
return <button type="button" onClick={onClick} title={title} className="flex items-center gap-2 w-full rounded hover:bg-muted/50 -mx-1 px-1 py-0.5">{body}</button>;
|
return <button type="button" onClick={onClick} title={title} className="flex items-center gap-2 w-full rounded cursor-pointer hover:bg-muted/50 -mx-1 px-1 py-0.5">{body}</button>;
|
||||||
}
|
}
|
||||||
return <div className="flex items-center gap-2">{body}</div>;
|
return <div className="flex items-center gap-2">{body}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShiftRow — a RIT / ΔTX offset control: on/off chip + a wheel-adjustable signed
|
|
||||||
// offset (±10 Hz per notch or per ± button) + a clear (0) button.
|
|
||||||
function ShiftRow({ label, on, hz, accent, onToggle, onDelta, onClear }: {
|
|
||||||
label: string; on: boolean; hz: number; accent: string;
|
|
||||||
onToggle: () => void; onDelta: (d: number) => void; onClear: () => void;
|
|
||||||
}) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const cb = useRef(onDelta); cb.current = onDelta;
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
const onWheel = (e: WheelEvent) => { e.preventDefault(); cb.current(e.deltaY < 0 ? 10 : -10); };
|
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
|
||||||
return () => el.removeEventListener('wheel', onWheel);
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Chip on={on} onClick={onToggle} label={label} />
|
|
||||||
<div ref={ref} title="Wheel / ± to shift"
|
|
||||||
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none cursor-ns-resize',
|
|
||||||
on ? 'border-border bg-muted/40' : 'border-border/60 bg-muted/20 opacity-60')}>
|
|
||||||
<button type="button" onClick={() => onDelta(-10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">−</button>
|
|
||||||
<span className="text-sm font-mono font-bold tabular-nums" style={{ color: on ? accent : undefined }}>
|
|
||||||
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz
|
|
||||||
</span>
|
|
||||||
<button type="button" onClick={() => onDelta(10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">+</button>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={onClear}
|
|
||||||
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">0</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
|
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
|
||||||
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
||||||
// the RST-tx value on click.
|
// the RST-tx value on click.
|
||||||
|
// Green to S9, amber through +20, red above — the Elecraft console's scale.
|
||||||
|
function sSegColor(frac: number) {
|
||||||
|
if (frac > 0.78) return '#dc2626';
|
||||||
|
if (frac > 0.55) return '#f59e0b';
|
||||||
|
return '#16a34a';
|
||||||
|
}
|
||||||
|
|
||||||
function sParts(v: number): { s: number; over: number; label: string } {
|
function sParts(v: number): { s: number; over: number; label: string } {
|
||||||
if (v >= 47) {
|
if (v >= 47) {
|
||||||
const over = Math.max(0, Math.round((v - 47) * 60 / 47));
|
const over = Math.max(0, Math.round((v - 47) * 60 / 47));
|
||||||
@@ -311,291 +364,48 @@ function wfColor(v: number): [number, number, number] {
|
|||||||
return WF_STOPS[WF_STOPS.length - 1][1];
|
return WF_STOPS[WF_STOPS.length - 1][1];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScopePanadapter — enables the rig's spectrum-scope stream and draws the
|
// The spectrum scope is GONE, deliberately. Every Icom streams its waveform
|
||||||
// reassembled sweep as a modern SDR panadapter: a glowing filled spectrum trace
|
// differently — the IC-7851 controls a scope it never streams, and a real
|
||||||
// on top and a scrolling colour waterfall below. Amplitudes are raw rig scale
|
// IC-7760 stops answering CI-V altogether a few frames in, taking CAT and
|
||||||
// (~0-160), normalised to the tallest recent peak so the trace fills the height.
|
// audio down with it — and chasing a per-model frame layout for a decoration
|
||||||
function ScopePanadapter() {
|
// is not worth a console that drops the link. The radio has a better scope
|
||||||
const { t } = useI18n();
|
// on its own front panel.
|
||||||
const [on, setOn] = useState(false);
|
|
||||||
const [fixed, setFixed] = useState(true);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
|
|
||||||
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
|
|
||||||
const holdRef = useRef<number[]>([]); // per-bin peak-hold line
|
|
||||||
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
|
|
||||||
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
|
|
||||||
const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
const next = !on;
|
|
||||||
setOn(next);
|
|
||||||
IcomSetScope(next).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Centre/pan the FIXED scope: set the edges to centre ±50 kHz (a 100 kHz
|
|
||||||
// window). "Centre" uses the live VFO; ◀/▶ shift the window by 50 kHz. This
|
|
||||||
// just writes the rig's fixed edges — simple and independent of the waveform
|
|
||||||
// decode.
|
|
||||||
const SCOPE_HALF = 50_000;
|
|
||||||
const applyEdges = (center: number) => {
|
|
||||||
if (center <= 0) return;
|
|
||||||
centerRef.current = center;
|
|
||||||
setFixed(true);
|
|
||||||
IcomSetScopeEdges(center - SCOPE_HALF, center + SCOPE_HALF).catch(() => {});
|
|
||||||
};
|
|
||||||
const centerOnVfo = async () => {
|
|
||||||
let c = vfoRef.current;
|
|
||||||
if (c <= 0) { try { const cs = await GetCATState(); c = cs?.freq_hz || 0; } catch {} }
|
|
||||||
applyEdges(c);
|
|
||||||
};
|
|
||||||
const pan = (dir: number) => applyEdges((centerRef.current || vfoRef.current) + dir * SCOPE_HALF);
|
|
||||||
const setMode = (nextFixed: boolean) => {
|
|
||||||
setFixed(nextFixed);
|
|
||||||
IcomSetScopeMode(nextFixed).catch(() => {});
|
|
||||||
};
|
|
||||||
// Stop the stream when the panel unmounts.
|
|
||||||
useEffect(() => () => { IcomSetScope(false).catch(() => {}); }, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!on) return;
|
|
||||||
let raf = 0, lastSeq = -1, alive = true;
|
|
||||||
const tick = async () => {
|
|
||||||
if (!alive) return;
|
|
||||||
try {
|
|
||||||
const sw = await IcomScopeData();
|
|
||||||
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
|
|
||||||
lastSeq = sw.seq;
|
|
||||||
setFixed(sw.fixed);
|
|
||||||
spanRef.current = { low: sw.low_hz, high: sw.high_hz };
|
|
||||||
let vfo = 0;
|
|
||||||
try {
|
|
||||||
const cs = await GetCATState();
|
|
||||||
vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0);
|
|
||||||
} catch {}
|
|
||||||
if (vfo > 0) vfoRef.current = vfo;
|
|
||||||
draw(sw.amp, sw.low_hz, sw.high_hz, vfoRef.current, sw.fixed);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
|
|
||||||
};
|
|
||||||
raf = requestAnimationFrame(tick);
|
|
||||||
return () => { alive = false; cancelAnimationFrame(raf); window.clearTimeout(raf); };
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [on]);
|
|
||||||
|
|
||||||
// Double-click tunes the rig to the clicked frequency.
|
|
||||||
const onDblClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
||||||
const cv = canvasRef.current;
|
|
||||||
const { low, high } = spanRef.current;
|
|
||||||
if (!cv || !(low > 0 && high > low)) return;
|
|
||||||
const rect = cv.getBoundingClientRect();
|
|
||||||
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
||||||
const hz = Math.round((low + frac * (high - low)) / 100) * 100; // nearest 100 Hz
|
|
||||||
SetCATFrequency(hz).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mouse-wheel over the scope QSYs ±100 Hz. Non-passive listener so we can
|
|
||||||
// preventDefault (else the page scrolls); optimistic vfoRef so quick spins
|
|
||||||
// accumulate before the poll reconciles.
|
|
||||||
useEffect(() => {
|
|
||||||
const el = canvasRef.current;
|
|
||||||
if (!el || !on) return;
|
|
||||||
const onWheel = (e: WheelEvent) => {
|
|
||||||
if (!vfoRef.current) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const next = vfoRef.current + (e.deltaY < 0 ? 100 : -100);
|
|
||||||
vfoRef.current = next;
|
|
||||||
SetCATFrequency(next).catch(() => {});
|
|
||||||
};
|
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
|
||||||
return () => el.removeEventListener('wheel', onWheel);
|
|
||||||
}, [on]);
|
|
||||||
|
|
||||||
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => {
|
|
||||||
const cv = canvasRef.current;
|
|
||||||
if (!cv) return;
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
const w = cv.clientWidth, h = cv.clientHeight;
|
|
||||||
if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
|
|
||||||
const ctx = cv.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
||||||
|
|
||||||
// Auto-scale: track the peak, decaying slowly so the floor doesn't jump.
|
|
||||||
const peak = Math.max(...amp);
|
|
||||||
peakRef.current = Math.max(peak, peakRef.current * 0.95, 40);
|
|
||||||
const scale = peakRef.current;
|
|
||||||
const n = amp.length;
|
|
||||||
|
|
||||||
// Background — deep navy vertical gradient.
|
|
||||||
const bg = ctx.createLinearGradient(0, 0, 0, h);
|
|
||||||
bg.addColorStop(0, '#0b1220'); bg.addColorStop(1, '#05070e');
|
|
||||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
|
||||||
|
|
||||||
// Grid.
|
|
||||||
ctx.strokeStyle = 'rgba(120,150,200,0.08)';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
|
||||||
for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
|
||||||
|
|
||||||
const xOf = (i: number) => (i / (n - 1)) * w;
|
|
||||||
const yOf = (v: number) => h - Math.min(1, v / scale) * h;
|
|
||||||
|
|
||||||
// Peak-hold line (slow decay) — a faint ghost of recent maxima.
|
|
||||||
const hold = holdRef.current;
|
|
||||||
if (hold.length !== n) hold.length = n, hold.fill(0);
|
|
||||||
for (let i = 0; i < n; i++) hold[i] = Math.max(amp[i], hold[i] * 0.92);
|
|
||||||
|
|
||||||
// Filled spectrum area.
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, h);
|
|
||||||
for (let i = 0; i < n; i++) ctx.lineTo(xOf(i), yOf(amp[i]));
|
|
||||||
ctx.lineTo(w, h); ctx.closePath();
|
|
||||||
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
|
||||||
grad.addColorStop(0, 'rgba(56,189,248,0.40)');
|
|
||||||
grad.addColorStop(1, 'rgba(56,189,248,0.02)');
|
|
||||||
ctx.fillStyle = grad; ctx.fill();
|
|
||||||
|
|
||||||
// Peak-hold trace (thin, faint).
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(hold[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
|
||||||
ctx.strokeStyle = 'rgba(148,197,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
|
|
||||||
|
|
||||||
// Live spectrum trace with a soft glow.
|
|
||||||
ctx.save();
|
|
||||||
ctx.shadowColor = 'rgba(56,189,248,0.7)'; ctx.shadowBlur = 6;
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(amp[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
|
||||||
ctx.strokeStyle = '#7dd3fc'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// VFO marker: you should ALWAYS see where you are. Exact position when the VFO
|
|
||||||
// is inside the span; the centre in CTR mode; clamped to the nearest edge with
|
|
||||||
// a sideways arrow in FIX mode when the fixed scope doesn't cover the VFO (so
|
|
||||||
// you can tell which way to tune to get it back on-screen).
|
|
||||||
const haveVfo = vfoHz > 0 && lowHz > 0 && highHz > lowHz;
|
|
||||||
const inSpan = haveVfo && vfoHz >= lowHz && vfoHz <= highHz;
|
|
||||||
let markerX = -1;
|
|
||||||
let offEdge = 0; // -1 = VFO off the left edge, +1 = off the right
|
|
||||||
if (inSpan) markerX = ((vfoHz - lowHz) / (highHz - lowHz)) * w;
|
|
||||||
else if (!fixedMode) markerX = w / 2;
|
|
||||||
else if (haveVfo) { offEdge = vfoHz < lowHz ? -1 : 1; markerX = offEdge < 0 ? 1 : w - 1; }
|
|
||||||
if (markerX >= 0) {
|
|
||||||
const x = markerX;
|
|
||||||
ctx.fillStyle = 'rgba(244,63,94,0.10)'; ctx.fillRect(x - 5, 0, 10, h);
|
|
||||||
ctx.strokeStyle = 'rgba(244,63,94,0.9)'; ctx.lineWidth = 1.25;
|
|
||||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
|
||||||
ctx.fillStyle = 'rgba(244,63,94,0.95)';
|
|
||||||
if (offEdge === 0) {
|
|
||||||
ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 6); ctx.closePath(); ctx.fill();
|
|
||||||
} else {
|
|
||||||
const yh = 8; ctx.beginPath(); ctx.moveTo(x, yh - 5); ctx.lineTo(x + offEdge * 7, yh); ctx.lineTo(x, yh + 5); ctx.closePath(); ctx.fill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Frequency scale. In fixed mode the rig reports usable edge frequencies, so
|
|
||||||
// we label low/centre/high from them. In centre mode the header frame's edge
|
|
||||||
// pair isn't a usable low..high range, but the scope is centred on the VFO —
|
|
||||||
// so we always label the centre with the live VFO frequency (which we fetch
|
|
||||||
// each sweep), and only add edge labels when the reported edges genuinely
|
|
||||||
// bracket the VFO. That guarantees you always see your frequency in CTR.
|
|
||||||
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
|
|
||||||
ctx.font = '10px ui-monospace, monospace';
|
|
||||||
ctx.textBaseline = 'bottom';
|
|
||||||
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3;
|
|
||||||
ctx.fillStyle = 'rgba(226,232,240,0.85)';
|
|
||||||
const label = (txt: string, x: number, align: CanvasTextAlign) => { ctx.textAlign = align; ctx.fillText(txt, x, h - 3); };
|
|
||||||
const validEdges = lowHz > 0 && highHz > lowHz;
|
|
||||||
if (fixedMode) {
|
|
||||||
if (validEdges) {
|
|
||||||
label(mhz(lowHz), 4, 'left');
|
|
||||||
label(mhz((lowHz + highHz) / 2), w / 2, 'center');
|
|
||||||
label(mhz(highHz), w - 4, 'right');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (validEdges && vfoHz >= lowHz && vfoHz <= highHz) {
|
|
||||||
label(mhz(lowHz), 4, 'left');
|
|
||||||
label(mhz(highHz), w - 4, 'right');
|
|
||||||
}
|
|
||||||
if (vfoHz > 0) label(mhz(vfoHz), w / 2, 'center');
|
|
||||||
}
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
|
|
||||||
drawWaterfall(amp, scale);
|
|
||||||
};
|
|
||||||
|
|
||||||
// drawWaterfall scrolls the history down one row and paints the newest sweep
|
|
||||||
// as a colour-mapped line at the top.
|
|
||||||
const drawWaterfall = (amp: number[], scale: number) => {
|
|
||||||
const cv = wfRef.current;
|
|
||||||
if (!cv) return;
|
|
||||||
const w = Math.max(1, cv.clientWidth), h = Math.max(1, cv.clientHeight);
|
|
||||||
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
|
|
||||||
const ctx = cv.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
// Scroll everything down by one pixel row.
|
|
||||||
ctx.drawImage(cv, 0, 0, w, h - 1, 0, 1, w, h - 1);
|
|
||||||
// Paint the new top row.
|
|
||||||
const row = ctx.createImageData(w, 1);
|
|
||||||
const n = amp.length;
|
|
||||||
for (let x = 0; x < w; x++) {
|
|
||||||
const i = Math.min(n - 1, Math.round((x / (w - 1)) * (n - 1)));
|
|
||||||
const [r, g, b] = wfColor(amp[i] / scale);
|
|
||||||
const o = x * 4;
|
|
||||||
row.data[o] = r; row.data[o + 1] = g; row.data[o + 2] = b; row.data[o + 3] = 255;
|
|
||||||
}
|
|
||||||
ctx.putImageData(row, 0, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Collapsible card: when the scope is off, only the header band shows (the
|
|
||||||
// canvas is hidden entirely) so it doesn't waste vertical space. The CTR/FIX
|
|
||||||
// and ON/OFF controls live in the header itself.
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
|
||||||
<Activity className="size-4" style={{ color: '#38bdf8' }} />
|
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('icmp.spectrum')}</span>
|
|
||||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
|
||||||
{on && (
|
|
||||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
|
||||||
<button type="button" onClick={() => pan(-1)} title={t('icmp.scopePanDown')}
|
|
||||||
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border">◀</button>
|
|
||||||
<button type="button" onClick={centerOnVfo} title={t('icmp.scopeCenterVfo')}
|
|
||||||
className="px-2 py-1 text-[11px] font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border">⊙</button>
|
|
||||||
<button type="button" onClick={() => pan(1)} title={t('icmp.scopePanUp')}
|
|
||||||
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted">▶</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{on && (
|
|
||||||
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
|
|
||||||
onChange={(v) => setMode(v === 'FIX')} />
|
|
||||||
)}
|
|
||||||
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{on && (
|
|
||||||
<div className="p-3">
|
|
||||||
<div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
|
|
||||||
<canvas ref={canvasRef} onDoubleClick={onDblClick}
|
|
||||||
className="w-full block cursor-crosshair" style={{ height: 140 }} />
|
|
||||||
<canvas ref={wfRef} className="w-full block" style={{ height: 96 }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// IcomPanel — full control surface (RX DSP + TX) for an Icom on the CI-V backend.
|
|
||||||
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
|
|
||||||
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
|
|
||||||
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
|
|
||||||
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
|
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
|
||||||
|
// The speaker toggle lives HERE, next to ON/OFF, because that is where the
|
||||||
|
// operator is looking — burying "stop listening" behind Settings → Audio
|
||||||
|
// meant a trip through two panels to mute a radio sitting in the same room.
|
||||||
|
const [listening, setListening] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNetwork) return;
|
||||||
|
let alive = true;
|
||||||
|
const ask = () => AudioMonitorActive().then((v) => { if (alive) setListening(!!v); }).catch(() => {});
|
||||||
|
ask();
|
||||||
|
const id = window.setInterval(ask, 2000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, [isNetwork]);
|
||||||
|
const toggleListening = () => {
|
||||||
|
const next = !listening;
|
||||||
|
setListening(next);
|
||||||
|
(next ? AudioStartMonitor() : Promise.resolve(AudioStopMonitor())).catch(() => setListening(!next));
|
||||||
|
};
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [st, setSt] = useState<IcomState>(ZERO);
|
const [st, setSt] = useState<IcomState>(ZERO);
|
||||||
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||||||
const [tuning, setTuning] = useState(false);
|
const [tuning, setTuning] = useState(false);
|
||||||
|
// Band buttons: recall the radio's own band stacking register instead of
|
||||||
|
// sending a frequency picked here. Remembered per operator, not per session —
|
||||||
|
// it is a preference about how a button behaves, and having to set it again
|
||||||
|
// at every launch would make it not worth having.
|
||||||
|
const [bandStack, setBandStack] = useState(() => localStorage.getItem('opslog.icomBandStack') === '1');
|
||||||
|
const toggleBandStack = () => setBandStack((v) => {
|
||||||
|
const n = !v;
|
||||||
|
try { localStorage.setItem('opslog.icomBandStack', n ? '1' : '0'); } catch { /* private mode */ }
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
// Which register each band was last recalled from, so pressing the same band
|
||||||
|
// again walks 1 → 2 → 3 → 1, exactly as the radio's own band key does.
|
||||||
|
const bandRegRef = useRef<Record<string, number>>({});
|
||||||
const txRef = useRef(false);
|
const txRef = useRef(false);
|
||||||
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
||||||
|
|
||||||
@@ -604,6 +414,18 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
|
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
|
||||||
};
|
};
|
||||||
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
|
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
|
||||||
|
|
||||||
|
// A band button. With the stacking registers on, ask the radio where the
|
||||||
|
// operator last was on that band; pressing the band it is already on steps to
|
||||||
|
// the next register, and the fixed frequency below is the fallback for a band
|
||||||
|
// or a model whose register the backend will not read — never a dead button.
|
||||||
|
const bandClick = (b: Band, here: boolean) => {
|
||||||
|
if (!bandStack) { SetCATFrequency(b.hz).catch(() => {}); return; }
|
||||||
|
const reg = here ? (bandRegRef.current[b.l] ?? 1) % 3 + 1 : 1;
|
||||||
|
IcomRecallBand(b.l, reg)
|
||||||
|
.then(() => { bandRegRef.current[b.l] = reg; load(); })
|
||||||
|
.catch(() => SetCATFrequency(b.hz).catch(() => {}));
|
||||||
|
};
|
||||||
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
|
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
|
||||||
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
@@ -627,7 +449,9 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
const toggleMox = () => {
|
const toggleMox = () => {
|
||||||
const next = !txRef.current;
|
const next = !txRef.current;
|
||||||
txRef.current = next;
|
txRef.current = next;
|
||||||
set({ transmitting: next }, () => IcomSetPTT(next));
|
// Through the console binding: on a network station the PC microphone
|
||||||
|
// rides with the PTT (silence otherwise); on USB it keys and nothing more.
|
||||||
|
set({ transmitting: next }, () => IcomConsolePTT(next));
|
||||||
};
|
};
|
||||||
|
|
||||||
const tune = async () => {
|
const tune = async () => {
|
||||||
@@ -670,8 +494,13 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
// other is TX (freq_hz); otherwise there's a single VFO (freq_hz).
|
// other is TX (freq_hz); otherwise there's a single VFO (freq_hz).
|
||||||
const split = !!cat?.split;
|
const split = !!cat?.split;
|
||||||
const mainHz: number = split ? (cat?.freq_rx_hz || 0) : (cat?.freq_hz || 0);
|
const mainHz: number = split ? (cat?.freq_rx_hz || 0) : (cat?.freq_hz || 0);
|
||||||
const subHz: number = split ? (cat?.freq_hz || 0) : 0;
|
// The sub receiver's dial is worth seeing whether or not split is on —
|
||||||
const curMode: string = cat?.mode || st.mode || '';
|
// in split the CAT state's TX freq is the authority, otherwise the panel's
|
||||||
|
// own sub_hz read.
|
||||||
|
const subHz: number = split ? (cat?.freq_hz || 0) : (st.sub_hz || 0);
|
||||||
|
// The panel's own mode first: it carries the sideband (USB/LSB) where the
|
||||||
|
// CAT state folds both into ADIF's SSB.
|
||||||
|
const curMode: string = st.mode || cat?.mode || '';
|
||||||
// Mode-dependent controls: VOX / speech-comp / mic are voice-only (hidden on
|
// Mode-dependent controls: VOX / speech-comp / mic are voice-only (hidden on
|
||||||
// CW and data); APF (audio peak filter) is CW-only. Fold USB/LSB into phone.
|
// CW and data); APF (audio peak filter) is CW-only. Fold USB/LSB into phone.
|
||||||
const um = curMode.toUpperCase();
|
const um = curMode.toUpperCase();
|
||||||
@@ -700,6 +529,14 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
rig's LAN server stays alive in standby, so both work. */}
|
rig's LAN server stays alive in standby, so both work. */}
|
||||||
{isNetwork && (
|
{isNetwork && (
|
||||||
<>
|
<>
|
||||||
|
<button type="button" onClick={toggleListening}
|
||||||
|
title={listening ? t('icmp.speakerOffHint') : t('icmp.speakerOnHint')}
|
||||||
|
className={cn('inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-bold',
|
||||||
|
listening
|
||||||
|
? 'border-primary/60 bg-primary/10 text-primary hover:bg-primary/20'
|
||||||
|
: 'border-border bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{listening ? <Volume2 className="size-3.5" /> : <VolumeX className="size-3.5" />}
|
||||||
|
</button>
|
||||||
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
||||||
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
||||||
<Power className="size-3.5" /> ON
|
<Power className="size-3.5" /> ON
|
||||||
@@ -741,9 +578,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
|
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
|
||||||
<div className="grid grid-cols-6 border-t border-border/60 divide-x divide-border/60">
|
<div className="grid border-t border-border/60 divide-x divide-border/60"
|
||||||
{MODES.map((m) => {
|
style={{ gridTemplateColumns: `repeat(${modesFor(st.model).length}, minmax(0, 1fr))` }}>
|
||||||
const on = modeMatches(m, curMode);
|
{modesFor(st.model).map((m) => {
|
||||||
|
const on = modeMatches(m, curMode, mainHz);
|
||||||
return (
|
return (
|
||||||
<button key={m} type="button" onClick={() => setMode(m)}
|
<button key={m} type="button" onClick={() => setMode(m)}
|
||||||
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
|
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
|
||||||
@@ -755,28 +593,41 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live meters — always visible: S (RX, click → RST), Po in watts, SWR. */}
|
{/* Live meters — the SAME LED MeterBar every other console uses (Flex,
|
||||||
<div className="rounded-xl border border-border bg-card px-3 py-2.5 shadow-sm grid grid-cols-1 sm:grid-cols-3 gap-x-5 gap-y-2">
|
Elecraft, the amp cards): one instrument look across the app, per the
|
||||||
|
operator's "les consoles doivent se ressembler". S is clickable → RST. */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
{(() => { const sp = sParts(st.s_meter); return (
|
{(() => { const sp = sParts(st.s_meter); return (
|
||||||
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
|
<MeterBar label="S-METER" value={st.transmitting ? 0 : st.s_meter} lo={0} hi={100}
|
||||||
|
accent="#16a34a" segColor={sSegColor}
|
||||||
|
display={st.transmitting ? '—' : sp.label}
|
||||||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||||||
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
|
onClick={onReportRST ? () => { if (!st.transmitting) onReportRST(sMeterRST(sp.s, sp.over, st.mode)); } : undefined} />
|
||||||
); })()}
|
); })()}
|
||||||
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter} W`} />
|
{(() => {
|
||||||
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
|
if ((st.model ?? '').includes('7760')) {
|
||||||
|
const { w, defl } = icomWatts(st.power_meter);
|
||||||
|
return <MeterBar label="PWR" value={defl} lo={0} hi={100} accent="#0ea5e9" display={`${w} W`} />;
|
||||||
|
}
|
||||||
|
return <MeterBar label="PWR" value={st.power_meter} lo={0} hi={100} accent="#0ea5e9" display={`${st.power_meter} W`} />;
|
||||||
|
})()}
|
||||||
|
<MeterBar label="SWR" value={st.swr_meter > 0 ? 1 + st.swr_meter / 33.3 : 0} lo={1} hi={4} accent="#f59e0b"
|
||||||
|
display={st.swr_meter > 0 ? (1 + st.swr_meter / 33.3).toFixed(1) : '—'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spectrum panadapter (full width). */}
|
|
||||||
<ScopePanadapter />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
{/* Band buttons + antenna selection. */}
|
{/* Band buttons + antenna selection. */}
|
||||||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||||
|
<label className="flex items-center gap-1.5 mb-1.5 text-[11px] text-muted-foreground cursor-pointer select-none"
|
||||||
|
title={t('icmp.bandStackHint')}>
|
||||||
|
<input type="checkbox" checked={bandStack} onChange={toggleBandStack} className="accent-primary" />
|
||||||
|
{t('icmp.bandStack')}
|
||||||
|
</label>
|
||||||
<div className="grid grid-cols-5 gap-1.5">
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
{bandsFor(st.model).map((b) => {
|
{bandsFor(st.model).map((b) => {
|
||||||
const here = bandOfHz(mainHz) === b.l;
|
const here = bandOfHz(mainHz) === b.l;
|
||||||
return (
|
return (
|
||||||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
<button key={b.l} type="button" onClick={() => bandClick(b, here)}
|
||||||
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
|
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
|
||||||
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
here
|
here
|
||||||
@@ -787,20 +638,22 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Row label={t('icmp.antenna')}>
|
{hasAntennaSelector(st.model) && (
|
||||||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
<Row label={t('icmp.antenna')}>
|
||||||
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||||
</Row>
|
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
||||||
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
|
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
|
||||||
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
||||||
onToggle={() => set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
|
onToggle={() => set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
|
||||||
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
onSet={setRit} />
|
||||||
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
|
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
|
||||||
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
|
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
|
||||||
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
onSet={setRit} />
|
||||||
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
|
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -808,9 +661,16 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
<Card icon={Mic} title={t('icmp.transmit')} accent="#ef4444">
|
<Card icon={Mic} title={t('icmp.transmit')} accent="#ef4444">
|
||||||
<Row label={t('icmp.power')}>
|
<Row label={t('icmp.power')}>
|
||||||
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
|
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
|
||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_power}</span>
|
{/* PC is a percentage of the rig's rated power; on a 200 W rig the
|
||||||
|
operator thinks in watts, so say it in watts there. */}
|
||||||
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">
|
||||||
|
{(st.model ?? '').includes('7760') ? `${st.rf_power * 2} W` : st.rf_power}
|
||||||
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
{isPhone && (
|
{/* Not phone-only: on USB-D the same control still sets what the radio
|
||||||
|
transmits at, and hiding it left an operator who lives in FT8 with
|
||||||
|
no mic gain at all. */}
|
||||||
|
{(
|
||||||
<Row label={t('icmp.mic')}>
|
<Row label={t('icmp.mic')}>
|
||||||
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
||||||
@@ -823,6 +683,9 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
{tx ? 'TX ON' : 'MOX'}
|
{tx ? 'TX ON' : 'MOX'}
|
||||||
</button>
|
</button>
|
||||||
<Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
|
<Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
|
||||||
|
{/* Tuner IN/OUT — TUNE below starts a cycle but could never take the
|
||||||
|
tuner back out of line. */}
|
||||||
|
<Chip label="ATU" on={!!st.atu_on} onClick={() => set({ atu_on: !st.atu_on } as any, () => IcomSetATU(!st.atu_on))} />
|
||||||
<button type="button" onClick={tune} disabled={tuning}
|
<button type="button" onClick={tune} disabled={tuning}
|
||||||
className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
tuning ? 'bg-warning border-warning text-warning-foreground animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
tuning ? 'bg-warning border-warning text-warning-foreground animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { WheelRange } from '@/components/WheelRange';
|
||||||
|
|
||||||
|
// LevelRow — a named level with a slider, a value you can type into, and ±.
|
||||||
|
//
|
||||||
|
// Shared, like ShiftRow, and for the same complaint: the consoles each drew
|
||||||
|
// their levels their own way. This is the wide shape — one row per level, the
|
||||||
|
// slider taking the width it needs — rather than two half-width sliders side by
|
||||||
|
// side, which is what "c'est laid et elles sont toutes petites" was about.
|
||||||
|
//
|
||||||
|
// Four ways to move it, so nobody has to learn ours: drag, wheel over the
|
||||||
|
// track, ± for one step, or click the number and type. Typing matters for the
|
||||||
|
// levels TCI reports in real units — a squelch at -95 dBm is a value an
|
||||||
|
// operator knows, not a position to hunt for with a mouse.
|
||||||
|
export function LevelRow({
|
||||||
|
label, value, min = 0, max = 100, step = 1, unit = '', accent, disabled, onSet,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
unit?: string;
|
||||||
|
accent?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onSet: (v: number) => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
|
const clamp = (v: number) => Math.max(min, Math.min(max, v));
|
||||||
|
const commit = (raw: string) => {
|
||||||
|
setEditing(null);
|
||||||
|
const v = parseInt(raw.replace(/[^0-9+-]/g, ''), 10);
|
||||||
|
if (!Number.isNaN(v)) onSet(clamp(v));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-24 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<WheelRange
|
||||||
|
min={min} max={max} step={step} value={value} disabled={disabled} accent={accent}
|
||||||
|
onChange={onSet}
|
||||||
|
className="h-2.5 flex-1 [&::-webkit-slider-thumb]:size-4"
|
||||||
|
/>
|
||||||
|
<div className={cn('flex items-center gap-0.5 shrink-0', disabled && 'opacity-40')}>
|
||||||
|
<button type="button" disabled={disabled} onClick={() => onSet(clamp(value - step))}
|
||||||
|
className="px-1.5 text-sm font-bold text-muted-foreground hover:text-foreground disabled:opacity-40">−</button>
|
||||||
|
{editing !== null ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editing}
|
||||||
|
onChange={(e) => setEditing(e.target.value)}
|
||||||
|
onBlur={(e) => commit(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') commit((e.target as HTMLInputElement).value);
|
||||||
|
else if (e.key === 'Escape') setEditing(null);
|
||||||
|
}}
|
||||||
|
className="w-14 rounded border border-border bg-background px-1 text-right text-xs font-mono tabular-nums outline-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button type="button" disabled={disabled} onClick={() => setEditing(String(value))}
|
||||||
|
className="w-14 text-right text-xs font-mono tabular-nums hover:text-primary disabled:cursor-default">
|
||||||
|
{value}{unit}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" disabled={disabled} onClick={() => onSet(clamp(value + step))}
|
||||||
|
className="px-1.5 text-sm font-bold text-muted-foreground hover:text-foreground disabled:opacity-40">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,16 +3,16 @@ import L from 'leaflet';
|
|||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { nightPolygon } from '../lib/greyline';
|
import { nightPolygon } from '../lib/greyline';
|
||||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||||
|
import { loadMapBase, saveMapBase, MAP_BASE_WORLD } from '@/lib/mapBase';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { formatDistance } from '@/lib/units';
|
||||||
|
import { loadMapView, saveMapView, MAP_VIEW_WORLD } from '@/lib/mapView';
|
||||||
|
|
||||||
// Persisted free-pan view of the world map (when auto-zoom is off).
|
// Persisted free-pan view of the world map (when auto-zoom is off).
|
||||||
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
|
const loadWorldView = () => loadMapView(MAP_VIEW_WORLD);
|
||||||
try { const v = JSON.parse(localStorage.getItem('opslog.mapView') || 'null'); return v && typeof v.zoom === 'number' ? v : null; }
|
function saveWorldView(m: L.Map) {
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
function saveMapView(m: L.Map) {
|
|
||||||
const c = m.getCenter();
|
const c = m.getCenter();
|
||||||
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
|
saveMapView(MAP_VIEW_WORLD, c.lat, c.lng, m.getZoom());
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Main tab is built from two independent map panes that the operator can
|
// The Main tab is built from two independent map panes that the operator can
|
||||||
@@ -27,6 +27,26 @@ function saveMapView(m: L.Map) {
|
|||||||
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
|
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
|
||||||
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
|
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
|
||||||
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
|
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
|
||||||
|
// homeView is where a world map should open for THIS station.
|
||||||
|
//
|
||||||
|
// Centred on 0° a world map puts Europe in the middle and leaves an Australian
|
||||||
|
// looking at their own country in the bottom-right corner, with the paths to
|
||||||
|
// everywhere they work running off both edges. Centred on their own longitude
|
||||||
|
// the same map reads the way their antenna does: the Americas to the east,
|
||||||
|
// Europe and Africa to the west. Leaflet repeats the world horizontally
|
||||||
|
// (worldCopyJump), so this is a choice of viewpoint and costs nothing — the
|
||||||
|
// tiles either side are the same world, and the great-circle code already draws
|
||||||
|
// across the seam (see unwrapLon).
|
||||||
|
//
|
||||||
|
// The latitude is damped, not followed: a station at 69° north centred on itself
|
||||||
|
// would spend half the map on the Arctic. It leans towards the operator's
|
||||||
|
// hemisphere and stops well short of the pole.
|
||||||
|
function homeView(grid: string): [number, number] {
|
||||||
|
const home = gridToLatLon(grid);
|
||||||
|
if (!home) return [20, 0];
|
||||||
|
return [Math.max(-35, Math.min(45, home.lat * 0.6 + 8)), home.lon];
|
||||||
|
}
|
||||||
|
|
||||||
function unwrapLon(ring: [number, number][]): [number, number][] {
|
function unwrapLon(ring: [number, number][]): [number, number][] {
|
||||||
const out: [number, number][] = [];
|
const out: [number, number][] = [];
|
||||||
let prev = NaN;
|
let prev = NaN;
|
||||||
@@ -42,8 +62,18 @@ function unwrapLon(ring: [number, number][]): [number, number][] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
// CARTO IS GONE, and it went the same way OpenStreetMap did.
|
||||||
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
//
|
||||||
|
// Their key-free basemaps now serve tiles stamped "API KEY REQUIRED" across
|
||||||
|
// the middle of the map — reported by an operator whose Light and Voyager views
|
||||||
|
// came up watermarked. They still answer 200 with a real image, which is why
|
||||||
|
// nothing here could detect it: the map looks like it is working and simply
|
||||||
|
// says otherwise in large grey letters.
|
||||||
|
//
|
||||||
|
// So both are replaced by Esri layers, the same provider already behind Street
|
||||||
|
// and Satellite. That is deliberate: a second key-free provider is a second
|
||||||
|
// chance to be cut off, and this one has already been serving the other two
|
||||||
|
// views for as long as OpsLog has had a map.
|
||||||
|
|
||||||
// NOT tile.openstreetmap.org. Those are OpenStreetMap's VOLUNTEER-run servers,
|
// NOT tile.openstreetmap.org. Those are OpenStreetMap's VOLUNTEER-run servers,
|
||||||
// and their usage policy does not cover a desktop application handed to an
|
// and their usage policy does not cover a desktop application handed to an
|
||||||
@@ -55,43 +85,75 @@ const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
|||||||
// still credits OpenStreetMap) and Esri. Anything added here later must be
|
// still credits OpenStreetMap) and Esri. Anything added here later must be
|
||||||
// checked the same way — a free tile URL is not the same thing as a tile URL we
|
// checked the same way — a free tile URL is not the same thing as a tile URL we
|
||||||
// are allowed to ship.
|
// are allowed to ship.
|
||||||
const ESRI_STREET = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}';
|
const ESRI = 'https://server.arcgisonline.com/ArcGIS/rest/services';
|
||||||
|
const ESRI_STREET = ESRI + '/World_Street_Map/MapServer/tile/{z}/{y}/{x}';
|
||||||
const ESRI_STREET_ATTR = 'Tiles © Esri — Source: Esri, HERE, Garmin, © OpenStreetMap contributors';
|
const ESRI_STREET_ATTR = 'Tiles © Esri — Source: Esri, HERE, Garmin, © OpenStreetMap contributors';
|
||||||
|
|
||||||
|
// The plain background the old Carto "Light" was: a pale canvas that lets the
|
||||||
|
// paths and the spots carry the colour. Its names come on a separate
|
||||||
|
// transparent layer, the same arrangement the satellite view already uses.
|
||||||
|
const ESRI_LIGHT = ESRI + '/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}';
|
||||||
|
const ESRI_LIGHT_LABELS = ESRI + '/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}';
|
||||||
|
const ESRI_LIGHT_ATTR = 'Tiles © Esri — Esri, DeLorme, NAVTEQ';
|
||||||
|
|
||||||
// Selectable basemaps for the world (great-circle) map. All key-free and all
|
// Selectable basemaps for the world (great-circle) map. All key-free and all
|
||||||
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
|
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
|
||||||
// overlay on top of an imagery basemap (so satellite keeps its names too).
|
// overlay on top of an imagery basemap (so satellite keeps its names too).
|
||||||
type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
|
export type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
|
||||||
const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
|
// maxNativeZoom is where a layer RUNS OUT of tiles. Leaflet then upscales the
|
||||||
light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' },
|
// last real one instead of asking for a level that does not exist, which is the
|
||||||
voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
// difference between a slightly soft map and a blank grey square: the light
|
||||||
attr: CARTO_ATTR, subdomains: 'abcd' },
|
// canvas stops at 16 where the imagery and the topo go on to 19.
|
||||||
|
export const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string; maxNativeZoom?: number }> = {
|
||||||
|
light: { label: 'Light', url: ESRI_LIGHT, attr: ESRI_LIGHT_ATTR, labelsUrl: ESRI_LIGHT_LABELS, maxNativeZoom: 16 },
|
||||||
|
// The key is still "voyager" so that everyone who chose it keeps their view
|
||||||
|
// rather than being silently moved to another one. What it draws is Esri's
|
||||||
|
// topographic map: terrain under the labels, which is what made Voyager worth
|
||||||
|
// choosing over the plain canvas.
|
||||||
|
voyager: { label: 'Topo', url: ESRI + '/World_Topo_Map/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
attr: 'Tiles © Esri — Esri, HERE, Garmin, USGS, NGA, © OpenStreetMap contributors' },
|
||||||
street: { label: 'Street', url: ESRI_STREET, attr: ESRI_STREET_ATTR },
|
street: { label: 'Street', url: ESRI_STREET, attr: ESRI_STREET_ATTR },
|
||||||
satellite: { label: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
satellite: { label: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
||||||
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
|
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
|
||||||
};
|
};
|
||||||
function loadBasemap(): BasemapKey {
|
// loadBasemap is the WORLD map's imagery. Each map keeps its own — see
|
||||||
const v = localStorage.getItem('opslog.mapBasemap');
|
// lib/mapBase, which is where the keys live.
|
||||||
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
export function loadBasemap(): BasemapKey {
|
||||||
|
return loadMapBase(MAP_BASE_WORLD, 'light');
|
||||||
}
|
}
|
||||||
|
|
||||||
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
|
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
|
||||||
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
|
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
|
||||||
// down: satellite loads TWO tile layers, so its tile count — and the composited
|
// down: satellite loads TWO tile layers, so its tile count — and the composited
|
||||||
// layers WebView2 has to hold — is double every other basemap's.
|
// layers WebView2 has to hold — is double every other basemap's.
|
||||||
function addBasemap(
|
export function addBasemap(
|
||||||
m: L.Map,
|
m: L.Map,
|
||||||
key: BasemapKey,
|
key: BasemapKey,
|
||||||
base: React.MutableRefObject<L.TileLayer | null>,
|
base: React.MutableRefObject<L.TileLayer | null>,
|
||||||
labels: React.MutableRefObject<L.TileLayer | null>,
|
labels: React.MutableRefObject<L.TileLayer | null>,
|
||||||
|
// noWrap draws the world ONCE instead of repeating it east and west.
|
||||||
|
//
|
||||||
|
// The great-circle map wants the repeats: a path from France to Japan reads
|
||||||
|
// better crossing a second copy of Asia than wrapping round the back. A map of
|
||||||
|
// where you have been heard does not — three North Americas is three times the
|
||||||
|
// same answer, and it makes the picture harder to read, not wider.
|
||||||
|
// bounds stops Leaflet asking for tiles outside the world at all: Esri answers
|
||||||
|
// those with a grey "Map data not yet available" image, and zoomed out on a
|
||||||
|
// wide window that is most of the screen.
|
||||||
|
opts?: { noWrap?: boolean; bounds?: L.LatLngBoundsExpression },
|
||||||
) {
|
) {
|
||||||
if (base.current) { m.removeLayer(base.current); base.current = null; }
|
if (base.current) { m.removeLayer(base.current); base.current = null; }
|
||||||
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
||||||
const bm = BASEMAPS[key];
|
const bm = BASEMAPS[key];
|
||||||
const opts: L.TileLayerOptions = { maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1 };
|
const tileOpts: L.TileLayerOptions = {
|
||||||
base.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
maxZoom: 19, maxNativeZoom: bm.maxNativeZoom ?? 19,
|
||||||
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||||||
|
noWrap: !!opts?.noWrap,
|
||||||
|
...(opts?.bounds ? { bounds: opts.bounds } : {}),
|
||||||
|
};
|
||||||
|
base.current = L.tileLayer(bm.url, { ...tileOpts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, tileOpts).addTo(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dot(color: string): L.DivIcon {
|
function dot(color: string): L.DivIcon {
|
||||||
@@ -136,31 +198,50 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
const autoZoomRef = useRef(autoZoom);
|
const autoZoomRef = useRef(autoZoom);
|
||||||
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
||||||
|
|
||||||
|
// WAIT FOR THE SQUARE BEFORE PAINTING.
|
||||||
|
//
|
||||||
|
// The station's own locator arrives from the profile a moment after this
|
||||||
|
// component mounts. Building the map immediately meant painting the world at
|
||||||
|
// 0°, then moving it to the operator's longitude — a screenful of tiles
|
||||||
|
// fetched from Esri and thrown away on every first run. The map is built once,
|
||||||
|
// when it knows where it is looking.
|
||||||
|
//
|
||||||
|
// Not for ever, though: an operator with no locator set (or a profile still
|
||||||
|
// loading after two seconds) gets the default view rather than a blank panel.
|
||||||
|
const [mapReady, setMapReady] = useState(() => !!gridToLatLon(fromGrid) || !!loadWorldView());
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapReady) return;
|
||||||
|
if (gridToLatLon(fromGrid)) { setMapReady(true); return; }
|
||||||
|
const t = window.setTimeout(() => setMapReady(true), 2000);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [fromGrid, mapReady]);
|
||||||
|
|
||||||
// One-time map creation.
|
// One-time map creation.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (worldRef.current && !worldMap.current) {
|
if (worldRef.current && !worldMap.current && mapReady) {
|
||||||
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
|
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
|
||||||
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
|
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
|
||||||
// composited paths re-rasterised on every pan, zoom and redraw, which is
|
// composited paths re-rasterised on every pan, zoom and redraw, which is
|
||||||
// enough to blow WebView2's raster budget and leave the window painting in
|
// enough to blow WebView2's raster budget and leave the window painting in
|
||||||
// patches. On canvas it is a single layer.
|
// patches. On canvas it is a single layer.
|
||||||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
|
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
|
||||||
.setView([20, 0], 1);
|
.setView(homeView(fromGrid), 1);
|
||||||
addBasemap(m, basemap, baseLayer, labelsLayer);
|
addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
worldOverlay.current = L.layerGroup().addTo(m);
|
worldOverlay.current = L.layerGroup().addTo(m);
|
||||||
worldMap.current = m;
|
worldMap.current = m;
|
||||||
const sv = loadMapView();
|
const sv = loadWorldView();
|
||||||
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
|
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
|
||||||
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
m.on('moveend', () => { if (!autoZoomRef.current) saveWorldView(m); });
|
||||||
}
|
}
|
||||||
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
||||||
|
|
||||||
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
|
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
|
||||||
// every overlay redraw (as before) forced a full repaint of the map each time
|
// every overlay redraw (as before) forced a full repaint of the map each time
|
||||||
// the rotor moved a degree.
|
// the rotor moved a degree.
|
||||||
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
|
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
|
||||||
if (worldRef.current) ro.observe(worldRef.current);
|
if (worldRef.current) ro.observe(worldRef.current);
|
||||||
return () => { window.clearTimeout(t); ro.disconnect(); };
|
return () => { window.clearTimeout(t); ro.disconnect(); };
|
||||||
}, []);
|
}, [mapReady]);
|
||||||
|
|
||||||
// Swap the basemap (and its optional place-name overlay) when the operator
|
// Swap the basemap (and its optional place-name overlay) when the operator
|
||||||
// picks a different one. Vector overlays (path/beam) live in Leaflet's
|
// picks a different one. Vector overlays (path/beam) live in Leaflet's
|
||||||
@@ -328,8 +409,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
|
|
||||||
if (autoZoom) {
|
if (autoZoom) {
|
||||||
if (from && to && arcPts) {
|
if (from && to && arcPts) {
|
||||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
// Latitudes clamped to Mercator's edge (±85°): the arc to a polar
|
||||||
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
// entity (Franz Josef Land) peaks near 88°N, and fitting the raw
|
||||||
|
// points framed a band of tile-less white above the top of the world.
|
||||||
|
// The line itself still draws to wherever it goes — only the CAMERA
|
||||||
|
// stays where there is a map to show.
|
||||||
|
const clamp = (lat: number) => Math.max(-85, Math.min(85, lat));
|
||||||
|
const bounds = L.latLngBounds([[clamp(from.lat), from.lon], [clamp(to.lat), to.lon]]);
|
||||||
|
arcPts.forEach((p) => bounds.extend([clamp(p[0]), p[1]] as L.LatLngExpression));
|
||||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||||
} else if (to) {
|
} else if (to) {
|
||||||
wm.setView([to.lat, to.lon], 3);
|
wm.setView([to.lat, to.lon], 3);
|
||||||
@@ -352,12 +439,15 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
||||||
<div ref={worldRef} className="absolute inset-0" />
|
<div ref={worldRef} className="absolute inset-0" />
|
||||||
{/* Basemap picker — Light / Street / Satellite (key-free tiles). */}
|
{/* Basemap picker — Light / Street / Satellite (key-free tiles). */}
|
||||||
<div className="absolute top-1 left-12 z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
|
{/* Clear of the zoom buttons, not merely next to them: at left-12 the
|
||||||
|
first basemap sat a few pixels from the − button and operators kept
|
||||||
|
hitting Light when they meant to zoom out. */}
|
||||||
|
<div className="absolute top-1 left-[4.5rem] z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
|
||||||
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||||
<button
|
<button
|
||||||
key={k}
|
key={k}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setBasemap(k); writeUiPref('opslog.mapBasemap', k); }}
|
onClick={() => { setBasemap(k); saveMapBase(MAP_BASE_WORLD, k); }}
|
||||||
title={`Basemap: ${BASEMAPS[k].label}`}
|
title={`Basemap: ${BASEMAPS[k].label}`}
|
||||||
className={`px-2 py-1 text-[11px] font-medium transition-colors ${
|
className={`px-2 py-1 text-[11px] font-medium transition-colors ${
|
||||||
basemap === k ? 'bg-primary text-primary-foreground' : 'bg-card/90 text-muted-foreground hover:bg-card'
|
basemap === k ? 'bg-primary text-primary-foreground' : 'bg-card/90 text-muted-foreground hover:bg-card'
|
||||||
@@ -376,7 +466,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
setAutoZoom(v);
|
setAutoZoom(v);
|
||||||
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
|
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
|
||||||
const m = worldMap.current;
|
const m = worldMap.current;
|
||||||
if (!v && m) saveMapView(m);
|
if (!v && m) saveWorldView(m);
|
||||||
}}
|
}}
|
||||||
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
|
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
|
||||||
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||||
@@ -402,8 +492,8 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
</button>
|
</button>
|
||||||
{path && (
|
{path && (
|
||||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
<div><span className="text-muted-foreground">Dist</span> {formatDistance(path.distanceShort)}
|
||||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
|
<span className="text-muted-foreground"> · LP</span> {formatDistance(path.distanceLong)}</div>
|
||||||
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
|
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
|
||||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
|
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -431,8 +521,12 @@ export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
|
|||||||
// single ring of buffer tiles. Requesting tiles as fast as the pointer
|
// single ring of buffer tiles. Requesting tiles as fast as the pointer
|
||||||
// moves is what gets an app blocked, and the next provider is under no
|
// moves is what gets an app blocked, and the next provider is under no
|
||||||
// more obligation to tolerate it than the last one was.
|
// more obligation to tolerate it than the last one was.
|
||||||
L.tileLayer(CARTO_LIGHT, {
|
L.tileLayer(ESRI_LIGHT, {
|
||||||
attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19,
|
attribution: ESRI_LIGHT_ATTR, maxZoom: 19, maxNativeZoom: 16,
|
||||||
|
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||||||
|
}).addTo(m);
|
||||||
|
L.tileLayer(ESRI_LIGHT_LABELS, {
|
||||||
|
maxZoom: 19, maxNativeZoom: 16,
|
||||||
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||||||
}).addTo(m);
|
}).addTo(m);
|
||||||
locatorOverlay.current = L.layerGroup().addTo(m);
|
locatorOverlay.current = L.layerGroup().addTo(m);
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
// MotorAntennaWidget — the Ultrabeam / SteppIR control, in one place.
|
||||||
|
//
|
||||||
|
// Lives in its own file because it is shown in TWO: the Station Control tab and
|
||||||
|
// the dock beside the entry strip. Two copies of a control that keys hardware is
|
||||||
|
// how one of them quietly stops matching the other — a pattern button that sets
|
||||||
|
// a different direction, a band list that tunes somewhere else.
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ArrowDownToLine, ChevronDown, ChevronUp, Loader2, Minus, Plus, RefreshCw, Antenna as AntennaIcon, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
|
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
export type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; track_mode?: string; bands?: string[]; band_freqs?: Record<string, number> };
|
||||||
|
|
||||||
|
// Where each band button points the antenna.
|
||||||
|
//
|
||||||
|
// Not the arithmetic centre of the band. An antenna is tuned for where people
|
||||||
|
// actually work: 20 m centres on 14175 but nobody lives there, and 10 m spans
|
||||||
|
// 1.7 MHz of which the top half is empty. These are the points that leave the
|
||||||
|
// elements closest to right for the whole band, and one nudge away from the rest.
|
||||||
|
const ANT_BAND_KHZ: Record<string, number> = {
|
||||||
|
'40m': 7100, '30m': 10125, '20m': 14150, '17m': 18110,
|
||||||
|
'15m': 21150, '12m': 24930, '10m': 28400, '6m': 50150,
|
||||||
|
};
|
||||||
|
|
||||||
|
// NUDGE_KHZ is the up/down step. 25 kHz because that is also the finest tracking
|
||||||
|
// threshold: a nudge smaller than the tracking step would be undone by the next
|
||||||
|
// poll while tracking is on.
|
||||||
|
const NUDGE_KHZ = 25;
|
||||||
|
|
||||||
|
// bandOfKHz names the band a frequency sits in, so a band button can light up
|
||||||
|
// when the ANTENNA is already there. Deliberately generous at the edges: the
|
||||||
|
// antenna's reported frequency is where it was commanded, which can sit slightly
|
||||||
|
// outside the allocation.
|
||||||
|
function bandOfKHz(khz: number): string {
|
||||||
|
const edges: [number, number, string][] = [
|
||||||
|
[6900, 7300, '40m'], [10050, 10200, '30m'], [13900, 14400, '20m'],
|
||||||
|
[18000, 18200, '17m'], [20900, 21500, '15m'], [24800, 25000, '12m'],
|
||||||
|
[27900, 29800, '10m'], [49900, 50600, '6m'],
|
||||||
|
];
|
||||||
|
for (const [lo, hi, b] of edges) if (khz >= lo && khz <= hi) return b;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
|
||||||
|
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
|
||||||
|
// only — per-element length adjustment. Heading/state is polled by the panel.
|
||||||
|
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press
|
||||||
|
|
||||||
|
// elementName maps an element index to a ham-radio name: 0 = reflector,
|
||||||
|
// 1 = driven element, then Director 1, 2, 3…
|
||||||
|
function elementName(i: number, t: (k: string, v?: any) => string): string {
|
||||||
|
if (i === 0) return t('station.reflector');
|
||||||
|
if (i === 1) return t('station.driven');
|
||||||
|
return `${t('station.director')} ${i - 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MotorAntennaWidget({ ant, refetch, t, onClose, essentialsOnly }: {
|
||||||
|
ant: AntStatus;
|
||||||
|
refetch: () => void;
|
||||||
|
t: (k: string, v?: any) => string;
|
||||||
|
// onClose adds the dock's close button. The Station Control tab passes none:
|
||||||
|
// there the widget is a tile in a grid, and a tile that closes itself would
|
||||||
|
// leave a hole the operator cannot fill back in.
|
||||||
|
onClose?: () => void;
|
||||||
|
// essentialsOnly drops what an operator sets once and then leaves alone:
|
||||||
|
// tracking with its mode and step, and the per-element lengths. They belong in
|
||||||
|
// Station Control, where there is room to think; in the dock they are height
|
||||||
|
// spent on controls nobody touches between contacts, pushing the ones they do
|
||||||
|
// touch — pattern, bands, nudge — off the bottom.
|
||||||
|
essentialsOnly?: boolean;
|
||||||
|
}) {
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
|
||||||
|
const [reading, setReading] = useState(false);
|
||||||
|
const [busyEl, setBusyEl] = useState<number | null>(null);
|
||||||
|
const [editingEl, setEditingEl] = useState<number | null>(null); // element whose mm is being typed
|
||||||
|
const [editVal, setEditVal] = useState('');
|
||||||
|
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
const isUB = ant.type !== 'steppir';
|
||||||
|
|
||||||
|
const readLengths = useCallback(async () => {
|
||||||
|
setReading(true); setErr('');
|
||||||
|
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
finally { setReading(false); }
|
||||||
|
}, []);
|
||||||
|
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
|
||||||
|
// +/- starts from the real values rather than blind.
|
||||||
|
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
|
||||||
|
|
||||||
|
// Send an absolute length to one element and remember it as the new baseline.
|
||||||
|
const setLen = async (i: number, mm: number) => {
|
||||||
|
const next = Math.max(0, Math.round(mm));
|
||||||
|
const prev = lengths[i] ?? 0;
|
||||||
|
setBusyEl(i); setErr('');
|
||||||
|
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
|
||||||
|
try { await MotorSetElement(i, next); refetch(); }
|
||||||
|
catch (e: any) {
|
||||||
|
// Rejected by the controller — most often the element is at its travel
|
||||||
|
// limit for this band (extending on a low band). Revert the optimistic
|
||||||
|
// value so the display stays truthful, and explain the likely cause when
|
||||||
|
// the failed move was an extension.
|
||||||
|
setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; });
|
||||||
|
setErr(next > prev ? t('station.atMax') : String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
finally { setBusyEl(null); }
|
||||||
|
};
|
||||||
|
// Nudge one element by ±2 mm from its current known length.
|
||||||
|
const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta);
|
||||||
|
// Commit a typed exact length (click on the mm value). Lets the operator fix
|
||||||
|
// the baseline when the auto-read is off, so +/- then work reliably.
|
||||||
|
const commitEdit = (i: number) => {
|
||||||
|
const v = parseInt(editVal, 10);
|
||||||
|
setEditingEl(null);
|
||||||
|
if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v);
|
||||||
|
};
|
||||||
|
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full flex flex-col min-h-0">
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border/60 bg-muted/30 shrink-0">
|
||||||
|
<AntennaIcon className="size-4 text-primary" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
|
||||||
|
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
|
||||||
|
</div>
|
||||||
|
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
|
||||||
|
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={ant.connected ? t('station.online') : t('station.offline')} />
|
||||||
|
{onClose && (
|
||||||
|
<button type="button" onClick={onClose} title={t('station.hideWidget')}
|
||||||
|
className="text-muted-foreground hover:text-foreground shrink-0">
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-2 space-y-2 flex-1 min-h-0 overflow-auto">
|
||||||
|
<div>
|
||||||
|
{/* No heading: N / 180° / Bi say what they are, and in a docked widget
|
||||||
|
a line of height costs more than the word explains. */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{dirs.map(([d, lbl]) => (
|
||||||
|
<button key={d} type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(SetUltrabeamDirection(d))}
|
||||||
|
className={cn('flex-1 rounded-md border py-1 text-xs font-semibold transition-colors disabled:opacity-40',
|
||||||
|
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||||||
|
{lbl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Bands, then a nudge, then tracking — in the order an operator uses
|
||||||
|
them: get to the band, fine-tune inside it, decide whether the
|
||||||
|
antenna should follow the rig from here. */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-0.5">{t('station.bands')}</div>
|
||||||
|
<div className="grid grid-cols-5 gap-1">
|
||||||
|
{(ant.bands ?? []).map((b: string) => {
|
||||||
|
// Where this band tunes is resolved by the backend — the operator's
|
||||||
|
// per-band choice from Settings, or the default. ANT_BAND_KHZ is only
|
||||||
|
// the floor for a status poll that hasn't landed yet.
|
||||||
|
const khz = ant.band_freqs?.[b] || ANT_BAND_KHZ[b];
|
||||||
|
if (!khz) return null;
|
||||||
|
// "On this band" from the antenna's own frequency, not the rig's:
|
||||||
|
// the widget must show where the ANTENNA is, which is the whole
|
||||||
|
// reason for tuning it by hand.
|
||||||
|
const here = ant.frequency > 0 && bandOfKHz(ant.frequency) === b;
|
||||||
|
return (
|
||||||
|
<button key={b} type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(MotorTuneKHz(khz))}
|
||||||
|
title={`${(khz / 1000).toFixed(3)} MHz`}
|
||||||
|
className={cn('rounded-md border py-0.5 text-[11px] font-mono font-semibold transition-colors disabled:opacity-40',
|
||||||
|
here ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||||||
|
{b}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(MotorNudgeKHz(-NUDGE_KHZ))}
|
||||||
|
title={t('station.nudgeDown', { n: NUDGE_KHZ })}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1 rounded-md border border-border py-1 text-xs font-semibold hover:bg-muted disabled:opacity-40">
|
||||||
|
<ChevronDown className="size-3.5" /> {NUDGE_KHZ}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(MotorNudgeKHz(NUDGE_KHZ))}
|
||||||
|
title={t('station.nudgeUp', { n: NUDGE_KHZ })}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1 rounded-md border border-border py-1 text-xs font-semibold hover:bg-muted disabled:opacity-40">
|
||||||
|
<ChevronUp className="size-3.5" /> {NUDGE_KHZ}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tracking. Here rather than only in Settings because it is an operating
|
||||||
|
decision — off to park the antenna, on to resume — not something set
|
||||||
|
up once. Mode and step only show when tracking is on: settings for
|
||||||
|
something switched off are questions the operator cannot act on. And
|
||||||
|
the step only shows in step mode, where it is the one thing it means. */}
|
||||||
|
{!essentialsOnly && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => run(SetMotorFollow(!ant.follow, 0, ''))}
|
||||||
|
className={cn('flex-1 rounded-md border py-1 text-xs font-semibold transition-colors',
|
||||||
|
ant.follow ? 'bg-success-muted text-success-muted-foreground border-success-border' : 'border-border hover:bg-muted')}>
|
||||||
|
{ant.follow ? t('station.trackOn') : t('station.trackOff')}
|
||||||
|
</button>
|
||||||
|
{ant.follow && (ant.track_mode || 'step') === 'step' && (
|
||||||
|
<select
|
||||||
|
value={String(ant.step_khz || 50)}
|
||||||
|
onChange={(e) => run(SetMotorFollow(true, parseInt(e.target.value, 10), ''))}
|
||||||
|
className="h-[30px] rounded-md border border-border bg-background px-1 text-xs font-mono"
|
||||||
|
title={t('station.trackStepTip')}
|
||||||
|
>
|
||||||
|
{[25, 50, 100].map((s) => <option key={s} value={s}>{s} kHz</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ant.follow && (
|
||||||
|
<select
|
||||||
|
value={ant.track_mode || 'step'}
|
||||||
|
onChange={(e) => run(SetMotorFollow(true, 0, e.target.value))}
|
||||||
|
className="w-full h-[30px] rounded-md border border-border bg-background px-1.5 text-xs"
|
||||||
|
title={t('station.trackModeTip')}
|
||||||
|
>
|
||||||
|
<option value="always" title={t('station.trackAlwaysTip')}>{t('station.trackAlways')}</option>
|
||||||
|
<option value="step" title={t('station.trackStepTipMode')}>{t('station.trackStep')}</option>
|
||||||
|
<option value="band" title={t('station.trackBandTip')}>{t('station.trackBand')}</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(UltrabeamRetract())}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||||
|
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isUB && !essentialsOnly && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
|
||||||
|
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
|
||||||
|
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
|
||||||
|
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{lengths.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{/* The READ_BANDS reply is undocumented and its 16-bit parse picks
|
||||||
|
up structural bytes past the real data (varying by band), which
|
||||||
|
made 6+ bogus "elements" appear. Ultrabeam beams are 3-element,
|
||||||
|
and ModifyElement addresses elements 0..2 — so show just those. */}
|
||||||
|
{lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
||||||
|
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||||
|
onClick={() => nudge(i, -ELEMENT_STEP)}
|
||||||
|
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||||
|
<Minus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
{editingEl === i ? (
|
||||||
|
<input autoFocus type="number" value={editVal}
|
||||||
|
onChange={(e) => setEditVal(e.target.value)}
|
||||||
|
onBlur={() => commitEdit(i)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }}
|
||||||
|
className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums cursor-pointer hover:underline"
|
||||||
|
title={t('station.setExactLen')}
|
||||||
|
onClick={() => { setEditingEl(i); setEditVal(String(mm)); }}>
|
||||||
|
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||||
|
onClick={() => nudge(i, ELEMENT_STEP)}
|
||||||
|
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ type QSOMenuHandlers = {
|
|||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
// PSKReporterPanel — can the station I am about to call actually hear me?
|
||||||
|
//
|
||||||
|
// The decodes list to the left says who is transmitting. It cannot say anything
|
||||||
|
// about the other direction, and on FT8 that is the whole question: the DX's
|
||||||
|
// pileup is invisible from here, and a station whose region is not open to
|
||||||
|
// yours will not hear you however many times you call.
|
||||||
|
//
|
||||||
|
// Every number here comes from PSK Reporter — reports uploaded by ordinary
|
||||||
|
// stations saying "I decoded X" — over a five-minute window. Nothing is
|
||||||
|
// inferred and nothing is remembered: when the window empties the panel says it
|
||||||
|
// does not know, which is the honest answer and the reason each block also says
|
||||||
|
// what it is measuring.
|
||||||
|
//
|
||||||
|
// The backend (internal/pskrtgt) does the analysis; this draws it and polls.
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Activity, ChevronRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { GetPSKAnalysis, SetPSKTarget } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
export type PSKEntry = {
|
||||||
|
call: string;
|
||||||
|
grid?: string;
|
||||||
|
snr: number;
|
||||||
|
offset_hz: number;
|
||||||
|
age_sec: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PSKAnalysis = {
|
||||||
|
target?: string;
|
||||||
|
mode?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
online: boolean;
|
||||||
|
spots: number;
|
||||||
|
he_me: boolean;
|
||||||
|
he_me_seconds: number;
|
||||||
|
he_me_snr: number;
|
||||||
|
he_me_offset_hz: number;
|
||||||
|
target_uploads: boolean;
|
||||||
|
target_grid?: string;
|
||||||
|
near_him_count: number;
|
||||||
|
near_him_top?: PSKEntry[];
|
||||||
|
from_my_area_count: number;
|
||||||
|
from_my_area_top?: PSKEntry[];
|
||||||
|
path_open: boolean;
|
||||||
|
heard_by_count: number;
|
||||||
|
heard_near_me: number;
|
||||||
|
heard_near_me_top?: PSKEntry[];
|
||||||
|
decoded_by_count: number;
|
||||||
|
decoded_by_top?: PSKEntry[];
|
||||||
|
decoded_by_calls?: string[];
|
||||||
|
pileup_count: number;
|
||||||
|
dial_hz: number;
|
||||||
|
ceiling_hz: number;
|
||||||
|
decodes_in_window: number;
|
||||||
|
bins?: { offset_hz: number; count: number; avg_snr: number }[];
|
||||||
|
suggested_offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// The station to analyse and the mode it was heard on. Set by clicking a
|
||||||
|
// decode, or by whoever the digital application says it is calling.
|
||||||
|
target: string;
|
||||||
|
mode?: string;
|
||||||
|
// The operator's own dial, which is what turns a report's frequency into an
|
||||||
|
// audio offset. Without it the passband block has nothing to say.
|
||||||
|
dialHz?: number;
|
||||||
|
// The local decodes, for "callers you hear": stations WE are decoding that
|
||||||
|
// are calling the same DX. That is the competition measured at this end,
|
||||||
|
// which no amount of PSK Reporter data can show.
|
||||||
|
callers: number;
|
||||||
|
callerCalls?: string[];
|
||||||
|
onCollapse: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The passband strip: 60 Hz bins, drawn from 200 Hz to 4000 Hz. The bin edges
|
||||||
|
// have to match the backend's alignment exactly — it keys them on multiples of
|
||||||
|
// 60 from zero, so a strip starting at 200 would ask for edges that never
|
||||||
|
// exist and draw an empty histogram over a busy passband.
|
||||||
|
const LO = 200, HI = 4000, STEP = 60;
|
||||||
|
const FIRST_EDGE = Math.floor(LO / STEP) * STEP;
|
||||||
|
const COLS = Math.floor((HI - FIRST_EDGE) / STEP);
|
||||||
|
|
||||||
|
export function PSKReporterPanel({ target, mode, dialHz, callers, callerCalls, onCollapse }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [a, setA] = useState<PSKAnalysis | null>(null);
|
||||||
|
|
||||||
|
// One second, matching the panel's own claim about how fresh it is. The call
|
||||||
|
// is a snapshot of an in-memory window — no query and no network of its own.
|
||||||
|
useEffect(() => {
|
||||||
|
let stop = false;
|
||||||
|
const tick = () => {
|
||||||
|
GetPSKAnalysis().then((r) => { if (!stop) setA(r as unknown as PSKAnalysis); }).catch(() => {});
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1000);
|
||||||
|
return () => { stop = true; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The target is re-asserted rather than sent once. The backend treats an
|
||||||
|
// unchanged callsign as a no-op, and this way a broker that dropped while
|
||||||
|
// nobody was looking comes back on its own instead of leaving a panel that
|
||||||
|
// is permanently, silently empty.
|
||||||
|
useEffect(() => {
|
||||||
|
SetPSKTarget(target ?? '', mode ?? '', dialHz ?? 0).catch(() => {});
|
||||||
|
if (!target) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
SetPSKTarget(target, mode ?? '', dialHz ?? 0).catch(() => {});
|
||||||
|
}, 15000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [target, mode, dialHz]);
|
||||||
|
|
||||||
|
const bins = a?.bins ?? [];
|
||||||
|
const maxCount = useMemo(() => bins.reduce((m, b) => Math.max(m, b.count || 0), 0) || 1, [bins]);
|
||||||
|
const byOffset = useMemo(() => {
|
||||||
|
const m = new Map<number, { count: number; avg_snr: number }>();
|
||||||
|
for (const b of bins) m.set(b.offset_hz, b);
|
||||||
|
return m;
|
||||||
|
}, [bins]);
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
const out: { edge: number; count: number; snr: number | null }[] = [];
|
||||||
|
for (let i = 0; i < COLS; i++) {
|
||||||
|
const edge = FIRST_EDGE + i * STEP;
|
||||||
|
const b = byOffset.get(edge);
|
||||||
|
out.push({ edge, count: b?.count ?? 0, snr: b?.avg_snr ?? null });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [byOffset]);
|
||||||
|
|
||||||
|
// Confirmed pileup: a station we hear calling this DX that the DX has also
|
||||||
|
// decoded. Two independent pieces of evidence, so it is the one number here
|
||||||
|
// that is not a proxy for anything.
|
||||||
|
const confirmed = useMemo(() => {
|
||||||
|
const heard = new Set((a?.decoded_by_calls ?? []).map((c) => c.toUpperCase()));
|
||||||
|
return (callerCalls ?? []).filter((c) => heard.has(c.toUpperCase())).length;
|
||||||
|
}, [a?.decoded_by_calls, callerCalls]);
|
||||||
|
|
||||||
|
const snr = (v: number) => `${v > 0 ? '+' : ''}${v}`;
|
||||||
|
|
||||||
|
const Tile = ({ label, value, foot, tone, title }: {
|
||||||
|
label: string; value: number | string; foot: string; tone: string; title?: string;
|
||||||
|
}) => (
|
||||||
|
<div className="px-2 py-1.5 rounded-md bg-muted/40 border border-border/60" title={title}>
|
||||||
|
<div className="text-[9px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||||
|
<div className={cn('text-lg font-bold leading-tight', tone)}>{value}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{foot}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[340px] shrink-0 flex flex-col min-h-0 border-l border-border bg-card">
|
||||||
|
{/* Header: what is being watched, and whether the feed is actually up. A
|
||||||
|
panel full of zeros means one of two very different things. */}
|
||||||
|
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border shrink-0">
|
||||||
|
<Activity className="size-4 text-primary shrink-0" />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t('psk.title')}
|
||||||
|
</span>
|
||||||
|
{target && <span className="text-xs font-mono text-foreground truncate">→ {target}</span>}
|
||||||
|
<span className="ml-auto flex items-center gap-2 text-[10px] shrink-0">
|
||||||
|
{a?.target && a.spots > 0 && (
|
||||||
|
<span className="text-muted-foreground" title={t('psk.spotsTip')}>{t('psk.spots', { n: a.spots })}</span>
|
||||||
|
)}
|
||||||
|
{a?.enabled === false
|
||||||
|
? <span className="text-muted-foreground">{t('psk.off')}</span>
|
||||||
|
: a?.online
|
||||||
|
? <span className="text-success">● {t('psk.online')}</span>
|
||||||
|
: <span className="text-muted-foreground">○ {t('psk.offline')}</span>}
|
||||||
|
<button type="button" onClick={onCollapse} title={t('psk.hide')}
|
||||||
|
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground">
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto px-2.5 py-2 space-y-3">
|
||||||
|
{a?.enabled === false ? (
|
||||||
|
<p className="text-xs text-muted-foreground italic">{t('psk.enableHint')}</p>
|
||||||
|
) : !target ? (
|
||||||
|
<p className="text-xs text-muted-foreground italic">{t('psk.pickHint')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── The answer ──────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn('shrink-0 size-8 rounded-full border flex items-center justify-center text-base',
|
||||||
|
a?.he_me ? 'bg-success/20 border-success/50 text-success'
|
||||||
|
: a?.path_open ? 'bg-warning/20 border-warning/50 text-warning'
|
||||||
|
: 'bg-muted border-border text-muted-foreground')}>
|
||||||
|
{a?.he_me ? '✓' : a?.path_open ? '≈' : '·'}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{a?.he_me ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-semibold text-success">{t('psk.heardYou', { s: a.he_me_seconds })}</div>
|
||||||
|
<div className="text-[11px] font-mono text-muted-foreground">
|
||||||
|
{snr(a.he_me_snr)} dB{a.he_me_offset_hz > 0 ? ` @ +${a.he_me_offset_hz} Hz` : ''}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : a?.path_open ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-semibold text-warning">{t('psk.pathOpen')}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">{t('psk.pathOpenSub', { n: a.from_my_area_count })}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-semibold text-muted-foreground">{t('psk.notYet')}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">{t('psk.notYetSub')}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Your signal reported next to him. Only when he has not decoded
|
||||||
|
you himself — that is strictly stronger evidence, and two
|
||||||
|
banners saying the same thing differently is noise. */}
|
||||||
|
{!a?.he_me && (a?.near_him_count ?? 0) > 0 && a?.target_grid && (
|
||||||
|
<div className="px-2 py-1.5 rounded-md bg-info/10 border border-info/30">
|
||||||
|
<div className="flex items-baseline justify-between gap-2 mb-0.5">
|
||||||
|
<span className="text-[10px] uppercase tracking-wide font-semibold text-info">
|
||||||
|
✓ {t('psk.nearHim', { g: a.target_grid })}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{t('psk.nRx', { n: a.near_him_count })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-2 font-mono text-[11px]">
|
||||||
|
{(a.near_him_top ?? []).map((h) => (
|
||||||
|
<span key={h.call} className="text-info" title={`${h.call} ${h.grid ?? ''} · ${h.age_sec}s`}>
|
||||||
|
{h.call} <span className="text-muted-foreground">{snr(h.snr)}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── The four numbers ────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<Tile label={t('psk.tFromArea')} value={a?.from_my_area_count ?? 0} foot={t('psk.tFromAreaFoot')}
|
||||||
|
tone="text-success"
|
||||||
|
title={(a?.from_my_area_top ?? []).map((h) => `${h.call} (${h.grid ?? '?'}) ${snr(h.snr)}`).join(' · ')} />
|
||||||
|
<Tile label={t('psk.tPileup')} value={a?.pileup_count ?? 0} foot={t('psk.tPileupFoot')}
|
||||||
|
tone="text-primary" title={t('psk.tPileupTip')} />
|
||||||
|
<Tile label={t('psk.tHeardNear')} value={a?.heard_near_me ?? 0} foot={t('psk.tHeardNearFoot')}
|
||||||
|
tone="text-info"
|
||||||
|
title={t('psk.tHeardNearTip', { n: a?.heard_by_count ?? 0 })} />
|
||||||
|
<Tile label={t('psk.tCallers')} value={confirmed > 0 ? `${callers} (${confirmed})` : callers}
|
||||||
|
foot={confirmed > 0 ? t('psk.tCallersFootConf') : t('psk.tCallersFoot')}
|
||||||
|
tone="text-warning" title={t('psk.tCallersTip')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The one thing that turns an empty panel from a verdict into a
|
||||||
|
missing measurement. */}
|
||||||
|
{a?.target_uploads ? (
|
||||||
|
<div className="text-[11px] text-success">✓ {t('psk.uploads')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-2 py-1.5 rounded-md bg-warning/10 border border-warning/30 text-[11px] text-warning">
|
||||||
|
⚠ {t('psk.noUploads', { c: target })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Who near you he is hearing ──────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground mb-0.5">{t('psk.fromAreaList')}</div>
|
||||||
|
{(a?.from_my_area_top ?? []).length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
|
||||||
|
{(a?.from_my_area_top ?? []).slice(0, 4).map((h) => (
|
||||||
|
<div key={h.call} className="flex items-baseline gap-2 truncate"
|
||||||
|
title={t('psk.rowTip', { c: h.call, g: h.grid ?? '?', s: h.age_sec, d: snr(h.snr) })}>
|
||||||
|
<span className="font-semibold text-foreground w-20 truncate">{h.call}</span>
|
||||||
|
<span className="text-muted-foreground w-12">({(h.grid ?? '?').slice(0, 4)})</span>
|
||||||
|
<span className="text-success w-14">{snr(h.snr)} dB</span>
|
||||||
|
{h.offset_hz > 0 && h.offset_hz < 10000 && (
|
||||||
|
<span className="ml-auto text-muted-foreground whitespace-nowrap">@ +{h.offset_hz} Hz</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[11px] text-muted-foreground italic">{t('psk.fromAreaEmpty')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── His passband ────────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{t('psk.passband')}</span>
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground">
|
||||||
|
{(a?.ceiling_hz ?? 0) > 0
|
||||||
|
? t('psk.ceiling', { hz: a!.ceiling_hz, n: a!.decodes_in_window })
|
||||||
|
: (a?.decodes_in_window ?? 0) > 0 ? t('psk.noDial') : t('psk.noDecodes')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex items-end gap-px h-10 rounded bg-muted/40 px-1 py-0.5 overflow-hidden">
|
||||||
|
{columns.map((c) => {
|
||||||
|
const ratio = c.count / maxCount;
|
||||||
|
return (
|
||||||
|
<div key={c.edge}
|
||||||
|
className={cn('flex-1 min-w-0 rounded-sm',
|
||||||
|
c.count === 0 ? 'bg-border'
|
||||||
|
: ratio > 0.66 ? 'bg-primary'
|
||||||
|
: ratio > 0.33 ? 'bg-primary/70' : 'bg-primary/40')}
|
||||||
|
style={{ height: `${Math.max(2, Math.round(ratio * 36))}px` }}
|
||||||
|
title={`${c.edge}-${c.edge + STEP} Hz · ${c.count}${c.snr !== null ? ` @ ${c.snr.toFixed(0)} dB` : ''}`} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(a?.suggested_offset ?? 0) > 0 && (
|
||||||
|
<div className="absolute top-0 bottom-0 w-0.5 bg-success pointer-events-none"
|
||||||
|
style={{ left: `${((a!.suggested_offset - LO) / (HI - LO)) * 100}%`, boxShadow: '0 0 4px currentColor' }}
|
||||||
|
title={t('psk.tryOffset', { hz: a!.suggested_offset })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative h-3 mt-0.5 text-[9px] font-mono text-muted-foreground">
|
||||||
|
{[1000, 2000, 3000, 4000].map((hz) => (
|
||||||
|
<span key={hz} className="absolute whitespace-nowrap"
|
||||||
|
style={{ left: `${((hz - LO) / (HI - LO)) * 100}%`, transform: `translateX(${hz === HI ? '-100%' : '-50%'})` }}>
|
||||||
|
{hz}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(a?.suggested_offset ?? 0) > 0 && (
|
||||||
|
<div className="text-center text-[11px] font-mono text-success mt-0.5">
|
||||||
|
🎯 {t('psk.tryOffset', { hz: a!.suggested_offset })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadFullLogHamQTH, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
@@ -31,7 +31,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type Confirmation = {
|
type Confirmation = {
|
||||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
callsign: string; station?: string; qso_date: string; band: string; mode: string; country: string;
|
||||||
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
|
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,6 +41,8 @@ const SERVICES = [
|
|||||||
{ v: 'hrdlog', label: 'HRDLog.net' },
|
{ v: 'hrdlog', label: 'HRDLog.net' },
|
||||||
{ v: 'eqsl', label: 'eQSL.cc' },
|
{ v: 'eqsl', label: 'eQSL.cc' },
|
||||||
{ v: 'lotw', label: 'LoTW' },
|
{ v: 'lotw', label: 'LoTW' },
|
||||||
|
{ v: 'hamlog', label: 'HAMLOG.online' },
|
||||||
|
{ v: 'hamqth', label: 'HamQTH' },
|
||||||
{ v: 'pota', label: 'POTA hunter log' },
|
{ v: 'pota', label: 'POTA hunter log' },
|
||||||
{ v: 'paper', label: 'Paper QSL' },
|
{ v: 'paper', label: 'Paper QSL' },
|
||||||
];
|
];
|
||||||
@@ -54,12 +56,14 @@ const QSL_STATUSES = [
|
|||||||
{ v: 'I', label: 'qslm.ignore' },
|
{ v: 'I', label: 'qslm.ignore' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
|
// QSL routing methods for the paper-QSL route dropdowns, one per direction.
|
||||||
|
// These are the ADIF QSL Via enumeration (B/D/E) and go to QSL_SENT_VIA and
|
||||||
|
// QSL_RCVD_VIA — NOT to QSL_VIA, which holds the manager's callsign.
|
||||||
const QSL_VIA_OPTIONS = [
|
const QSL_VIA_OPTIONS = [
|
||||||
{ v: '_', label: 'qslm.leave' },
|
{ v: '_', label: 'qslm.leave' },
|
||||||
{ v: 'Bureau', label: 'qslm.viaBureau' },
|
{ v: 'B', label: 'qslm.viaBureau' },
|
||||||
{ v: 'Direct', label: 'qslm.viaDirect' },
|
{ v: 'D', label: 'qslm.viaDirect' },
|
||||||
{ v: 'Electronic', label: 'qslm.viaElectronic' },
|
{ v: 'E', label: 'qslm.viaElectronic' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Maps a service value → its i18n label key (only for services with
|
// Maps a service value → its i18n label key (only for services with
|
||||||
@@ -109,7 +113,38 @@ export function fmtQslDate(s?: string): string {
|
|||||||
|
|
||||||
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
|
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
|
||||||
// and download confirmations, while the rest of the app stays usable.
|
// and download confirmations, while the rest of the app stays usable.
|
||||||
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
// GridActions are the row actions the context menu offers, forwarded straight to
|
||||||
|
// whichever grid this panel is showing.
|
||||||
|
//
|
||||||
|
// The FILTERED exports are deliberately absent. "Export everything the filter
|
||||||
|
// matches" means the Recent-QSOs filter, and the rows here come from a different
|
||||||
|
// query entirely — offering it would export something other than what is on the
|
||||||
|
// screen, which is the one thing an export must never do. Selection-based
|
||||||
|
// exports have no such ambiguity: the ids are the rows you ticked.
|
||||||
|
export type GridActions = {
|
||||||
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||||
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
|
onSendRecording?: (ids: number[]) => void;
|
||||||
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
|
onDelete?: (ids: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||||
|
onEditQSO?: (id: number) => void;
|
||||||
|
actions?: GridActions;
|
||||||
|
// A callsign to open the Paper QSL view on, sent from elsewhere in the app
|
||||||
|
// (the awards grid). Carries a counter rather than being a bare string: the
|
||||||
|
// panel is force-mounted and keeps its state, so asking twice for the SAME
|
||||||
|
// callsign has to be two requests, not one unchanged prop.
|
||||||
|
paperRequest?: { call: string; n: number };
|
||||||
|
} = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [service, setService] = useState('lotw');
|
const [service, setService] = useState('lotw');
|
||||||
// The callsign this profile signs/uploads/downloads as for the selected
|
// The callsign this profile signs/uploads/downloads as for the selected
|
||||||
@@ -149,7 +184,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const [qslSent, setQslSent] = useState('_');
|
const [qslSent, setQslSent] = useState('_');
|
||||||
const [qslRcvdDate, setQslRcvdDate] = useState('');
|
const [qslRcvdDate, setQslRcvdDate] = useState('');
|
||||||
const [qslSentDate, setQslSentDate] = useState('');
|
const [qslSentDate, setQslSentDate] = useState('');
|
||||||
const [qslVia, setQslVia] = useState('_');
|
const [qslVia, setQslVia] = useState('_'); // how the card was SENT
|
||||||
|
const [qslRcvdVia, setQslRcvdVia] = useState('_'); // how it CAME BACK
|
||||||
const [qslNotes, setQslNotes] = useState('');
|
const [qslNotes, setQslNotes] = useState('');
|
||||||
const [qslComment, setQslComment] = useState('');
|
const [qslComment, setQslComment] = useState('');
|
||||||
|
|
||||||
@@ -169,6 +205,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
}, [paperCall]);
|
}, [paperCall]);
|
||||||
|
|
||||||
|
|
||||||
|
// Honour an incoming request: switch to Paper QSL, fill the callsign, search.
|
||||||
|
// The search runs from the effect below once paperCall has actually changed —
|
||||||
|
// searchPaper closes over paperCall, so calling it here would search the
|
||||||
|
// PREVIOUS callsign.
|
||||||
|
// The pending request's callsign, so the search fires for THAT callsign and
|
||||||
|
// for nothing else: the effect below also wakes on every keystroke in the
|
||||||
|
// field, and searching on each of them would query the log letter by letter.
|
||||||
|
const pendingPaperCall = useRef('');
|
||||||
|
useEffect(() => {
|
||||||
|
const call = paperRequest?.call?.trim().toUpperCase();
|
||||||
|
if (!call) return;
|
||||||
|
setService('paper');
|
||||||
|
setPaperCall(call);
|
||||||
|
pendingPaperCall.current = call;
|
||||||
|
}, [paperRequest?.call, paperRequest?.n]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingPaperCall.current || paperCall !== pendingPaperCall.current) return;
|
||||||
|
pendingPaperCall.current = '';
|
||||||
|
searchPaper();
|
||||||
|
}, [paperCall, searchPaper]);
|
||||||
|
|
||||||
async function applyPaper() {
|
async function applyPaper() {
|
||||||
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
|
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
@@ -181,6 +238,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
sent_date: ymd(qslSentDate),
|
sent_date: ymd(qslSentDate),
|
||||||
rcvd_date: ymd(qslRcvdDate),
|
rcvd_date: ymd(qslRcvdDate),
|
||||||
via: qslVia === '_' ? '' : qslVia,
|
via: qslVia === '_' ? '' : qslVia,
|
||||||
|
rcvd_via: qslRcvdVia === '_' ? '' : qslRcvdVia,
|
||||||
notes: qslNotes,
|
notes: qslNotes,
|
||||||
comment: qslComment,
|
comment: qslComment,
|
||||||
} as any);
|
} as any);
|
||||||
@@ -198,6 +256,15 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [addNotFound, setAddNotFound] = useState(false);
|
const [addNotFound, setAddNotFound] = useState(false);
|
||||||
|
// LoTW only: pull the whole account rather than this profile's callsign.
|
||||||
|
const [lotwAllCalls, setLotwAllCalls] = useState(false);
|
||||||
|
// LoTW only: ask for the QSL dates and station details. Ten times slower to
|
||||||
|
// build, so it is a choice rather than the default it used to be.
|
||||||
|
const [lotwDetail, setLotwDetail] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
GetLoTWDownloadAllCalls().then((v: boolean) => setLotwAllCalls(!!v)).catch(() => {});
|
||||||
|
GetLoTWQSLDetail().then((v: boolean) => setLotwDetail(!!v)).catch(() => {});
|
||||||
|
}, []);
|
||||||
// Download date window: 'last' = incremental since last pull, 'date' = from a
|
// Download date window: 'last' = incremental since last pull, 'date' = from a
|
||||||
// chosen date, 'all' = everything.
|
// chosen date, 'all' = everything.
|
||||||
const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last');
|
const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last');
|
||||||
@@ -293,6 +360,34 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HAMLOG.online has no download verb — their agent protocol only uploads —
|
||||||
|
// so confirmations come back as an ADIF exported from their site. It is read
|
||||||
|
// here rather than through the ordinary ADIF import because that one matches
|
||||||
|
// on the UTC minute and INSERTS whatever fails to match: a few hundred copies
|
||||||
|
// of contacts already in the log. This path only ever stamps QSOs it finds.
|
||||||
|
async function importHamlogCfm() {
|
||||||
|
const path = await OpenADIFFile();
|
||||||
|
if (!path) return;
|
||||||
|
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
|
||||||
|
try { await ImportHamlogConfirmations(path); }
|
||||||
|
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unmatched half of an import. Offered as a file because a few hundred
|
||||||
|
// discrepancies are a list to work through, not something to read in a log
|
||||||
|
// window.
|
||||||
|
async function exportHamlogUnmatched() {
|
||||||
|
try {
|
||||||
|
const path = await SaveADIFFile();
|
||||||
|
if (!path) return;
|
||||||
|
const n = await ExportHamlogUnmatched(path);
|
||||||
|
setLogLines((p) => [...p, `Exported ${n} unmatched confirmation(s) → ${path}`]);
|
||||||
|
setShowLog(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setShowLog(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function viewResults() {
|
function viewResults() {
|
||||||
setShowLog(false);
|
setShowLog(false);
|
||||||
if (logAction === 'upload') selectRequired();
|
if (logAction === 'upload') selectRequired();
|
||||||
@@ -349,6 +444,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||||
{t('qslm.search')}
|
{t('qslm.search')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* The station's QRZ page, one click away: writing a card means
|
||||||
|
reading the address, the manager and whether they even want
|
||||||
|
paper, and all three are on that page. */}
|
||||||
|
<Button size="sm" variant="outline" className="h-8" disabled={!paperCall.trim()}
|
||||||
|
title={t('qslm.qrzTitle')}
|
||||||
|
onClick={() => {
|
||||||
|
const c = paperCall.trim().toUpperCase();
|
||||||
|
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch(() => {});
|
||||||
|
}}>
|
||||||
|
<ExternalLink className="size-3.5" />
|
||||||
|
QRZ
|
||||||
|
</Button>
|
||||||
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span>
|
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -439,6 +546,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
total={paperRows.length}
|
total={paperRows.length}
|
||||||
selectAllSignal={paperSelAllSig}
|
selectAllSignal={paperSelAllSig}
|
||||||
onRowSelected={(ids) => setPaperSel(new Set(ids))}
|
onRowSelected={(ids) => setPaperSel(new Set(ids))}
|
||||||
|
{...actions}
|
||||||
|
onRowDoubleClicked={onEditQSO ? (q) => onEditQSO(q.id as number) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -511,6 +620,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<thead className="sticky top-0 bg-card">
|
<thead className="sticky top-0 bg-card">
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<tr className="text-left text-muted-foreground border-b border-border">
|
||||||
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
|
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
|
||||||
|
{/* Which of the operator's callsigns the contact was made
|
||||||
|
under. Only worth a column when the list can hold more than
|
||||||
|
one — see "All my callsigns" on the download. */}
|
||||||
|
{shownConfs.some((c) => c.station) && <th className="py-1.5 px-2">{t('qslm.thStation')}</th>}
|
||||||
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
|
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
|
||||||
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
|
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -520,6 +633,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<tr key={i} className="border-b border-border/40">
|
<tr key={i} className="border-b border-border/40">
|
||||||
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
|
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
|
||||||
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
|
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
|
||||||
|
{shownConfs.some((x) => x.station) && (
|
||||||
|
<td className="py-1 px-2 font-mono text-muted-foreground">{c.station ?? ''}</td>
|
||||||
|
)}
|
||||||
<td className="py-1 px-2">{c.band}</td>
|
<td className="py-1 px-2">{c.band}</td>
|
||||||
<td className="py-1 px-2">{c.mode}</td>
|
<td className="py-1 px-2">{c.mode}</td>
|
||||||
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
||||||
@@ -545,6 +661,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
total={rows.length}
|
total={rows.length}
|
||||||
selectAllSignal={uploadSelAllSig}
|
selectAllSignal={uploadSelAllSig}
|
||||||
onRowSelected={onUploadRowSelected}
|
onRowSelected={onUploadRowSelected}
|
||||||
|
{...actions}
|
||||||
|
onRowDoubleClicked={onEditQSO ? (q) => onEditQSO(q.id as number) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -553,6 +671,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
{/* Paper-QSL apply form */}
|
{/* Paper-QSL apply form */}
|
||||||
{service === 'paper' && (
|
{service === 'paper' && (
|
||||||
<div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
<div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||||
|
{/* Each direction carries its own route, and each sits with the status
|
||||||
|
and date it belongs to. A stack confirmed in one go usually arrived
|
||||||
|
the same way — a bureau delivery is exactly that — which is why the
|
||||||
|
route belongs on this form and not one QSO at a time in the editor. */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslReceived')}</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslReceived')}</label>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
@@ -561,6 +683,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title={t('qslm.qslRcvdDateTitle')} />
|
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title={t('qslm.qslRcvdDateTitle')} />
|
||||||
|
<Select value={qslRcvdVia} onValueChange={setQslRcvdVia}>
|
||||||
|
<SelectTrigger className="h-8 w-32" title={t('qslm.rcvdViaTitle')}><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -571,15 +697,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} />
|
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} />
|
||||||
|
<Select value={qslVia} onValueChange={setQslVia}>
|
||||||
|
<SelectTrigger className="h-8 w-32" title={t('qslm.sentViaTitle')}><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.via')}</label>
|
|
||||||
<Select value={qslVia} onValueChange={setQslVia}>
|
|
||||||
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.notes')}</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.notes')}</label>
|
||||||
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
|
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
|
||||||
@@ -600,11 +723,43 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
{service !== 'pota' && service !== 'paper' && (
|
{service !== 'pota' && service !== 'paper' && (
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{service === 'hamqth' ? (
|
||||||
|
// HamQTH's file endpoint REPLACES the remote log — its own
|
||||||
|
// documentation is explicit that partial uploads do not exist. So
|
||||||
|
// it is offered as its own deliberate act, never as the batch path
|
||||||
|
// behind "send these": that would delete everything not selected.
|
||||||
|
<Button variant="outline" size="sm" disabled={busy}
|
||||||
|
title={t('qslm.hqFullTitle')}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(t('qslm.hqFullConfirm'))) return;
|
||||||
|
// Same three lines every other action here runs: without
|
||||||
|
// setShowLog the whole upload reported itself into a panel
|
||||||
|
// nobody was showing, and the tab sat on "Pick a service".
|
||||||
|
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||||
|
try { await UploadFullLogHamQTH(); }
|
||||||
|
catch (e: any) { setBusy(false); setLogLines((l) => [...l, String(e?.message ?? e)]); }
|
||||||
|
}}>
|
||||||
|
<UploadCloud className="size-3.5" /> {t('qslm.hqFull')}
|
||||||
|
</Button>
|
||||||
|
) : service === 'hamlog' ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
|
||||||
|
title={t('qslm.hamlogImportTitle')}>
|
||||||
|
<DownloadCloud className="size-3.5" /> {t('qslm.hamlogImportCfm')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={exportHamlogUnmatched} disabled={busy}
|
||||||
|
title={t('qslm.hamlogUnmatchedTitle')}>
|
||||||
|
<UploadCloud className="size-3.5 rotate-180" /> {t('qslm.hamlogUnmatched')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||||
title={t('qslm.downloadTitle')}>
|
title={t('qslm.downloadTitle')}>
|
||||||
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
|
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
{/* Date window */}
|
{/* Date window */}
|
||||||
|
{service !== 'hamlog' && (<>
|
||||||
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
|
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
|
||||||
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
|
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -628,6 +783,20 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||||
{t('qslm.addNotFound')}
|
{t('qslm.addNotFound')}
|
||||||
</label>
|
</label>
|
||||||
|
{service === 'lotw' && (
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title={t('qslm.lotwDetailTitle')}>
|
||||||
|
<Checkbox checked={lotwDetail || addNotFound} disabled={addNotFound}
|
||||||
|
onCheckedChange={(c) => { setLotwDetail(!!c); SetLoTWQSLDetail(!!c); }} />
|
||||||
|
{t('qslm.lotwDetail')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{service === 'lotw' && (
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title={t('qslm.lotwAllCallsTitle')}>
|
||||||
|
<Checkbox checked={lotwAllCalls} onCheckedChange={(c) => { setLotwAllCalls(!!c); SetLoTWDownloadAllCalls(!!c); }} />
|
||||||
|
{t('qslm.lotwAllCalls')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
||||||
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
|
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2, MapPin } from 'lucide-react';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||||
@@ -7,16 +7,22 @@ export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
|||||||
type Props = {
|
type Props = {
|
||||||
menu: QSOMenuState;
|
menu: QSOMenuState;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdateFromCty: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
// Only passed once the offline US county database has been downloaded —
|
||||||
|
// an entry that can only ever answer "no database" is not worth a line here.
|
||||||
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
// Same rows, but keeping the APP_OPSLOG_* tags — award references above all.
|
||||||
|
onExportSelectedFull?: (ids: number[]) => void;
|
||||||
onExportSelectedFields?: (ids: number[]) => void;
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
|
onExportFilteredFull?: () => void;
|
||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
@@ -28,6 +34,8 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
|||||||
{ service: 'hrdlog', name: 'HRDLog.net' },
|
{ service: 'hrdlog', name: 'HRDLog.net' },
|
||||||
{ service: 'eqsl', name: 'eQSL.cc' },
|
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||||
{ service: 'lotw', name: 'LoTW' },
|
{ service: 'lotw', name: 'LoTW' },
|
||||||
|
{ service: 'hamqth', name: 'HamQTH' },
|
||||||
|
{ service: 'cloudlog', name: 'Cloudlog / Wavelog' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||||
@@ -36,7 +44,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
|||||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||||
// which used to dismiss the menu the instant it appeared.)
|
// which used to dismiss the menu the instant it appeared.)
|
||||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFull, onExportSelectedFields, onExportFiltered, onExportFilteredFull, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
// Starts at the cursor; the layout effect corrects it once the real size is
|
// Starts at the cursor; the layout effect corrects it once the real size is
|
||||||
@@ -95,20 +103,28 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
{t('qctx.selected', { n })}
|
{t('qctx.selected', { n })}
|
||||||
</div>
|
</div>
|
||||||
<button
|
{/* Conditional like every other entry. These two were drawn
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
unconditionally, and the grid passes them through an arrow that calls
|
||||||
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
an optional handler — so in a grid that wires neither (the QSL
|
||||||
>
|
Manager) both appeared and did precisely nothing when clicked. */}
|
||||||
<Globe2 className="size-4 text-primary" />
|
{onUpdateFromCty && (
|
||||||
<span>{t('qctx.fixCountry')}</span>
|
<button
|
||||||
</button>
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
<button
|
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
>
|
||||||
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
<Globe2 className="size-4 text-primary" />
|
||||||
>
|
<span>{t('qctx.fixCountry')}</span>
|
||||||
<RefreshCw className="size-4 text-info" />
|
</button>
|
||||||
<span>{t('qctx.updateQrz')}</span>
|
)}
|
||||||
</button>
|
{onUpdateFromQRZ && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-info" />
|
||||||
|
<span>{t('qctx.updateQrz')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onUpdateFromClublog && (
|
{onUpdateFromClublog && (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
@@ -118,6 +134,16 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<span>{t('qctx.updateClublog')}</span>
|
<span>{t('qctx.updateClublog')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onUpdateCountyFromULS && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onUpdateCountyFromULS(menu.ids); onClose(); }}
|
||||||
|
title={t('qctx.updateUlsCountyTitle')}
|
||||||
|
>
|
||||||
|
<MapPin className="size-4 text-info" />
|
||||||
|
<span>{t('qctx.updateUlsCounty')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{(onSendRecording || onSendEQSL) && (
|
{(onSendRecording || onSendEQSL) && (
|
||||||
<>
|
<>
|
||||||
@@ -168,6 +194,20 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* The same rows WITH the OpsLog fields.
|
||||||
|
The export above is standard ADIF — what another logger should be
|
||||||
|
given — and it drops every APP_ tag, award references included. A
|
||||||
|
backup, or a move to another OpsLog, needs them: RDA@KR-04 lives in
|
||||||
|
APP_OPSLOG_AWARDREFS and nowhere else. */}
|
||||||
|
{onExportSelectedFull && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportSelectedFull(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-info" />
|
||||||
|
<span>{t('qctx.exportSelectedAdifFull', { n })}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onExportSelectedFields && (
|
{onExportSelectedFields && (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
@@ -186,6 +226,15 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<span>{t('qctx.exportFilteredAdif')}</span>
|
<span>{t('qctx.exportFilteredAdif')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onExportFilteredFull && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportFilteredFull(); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-info" />
|
||||||
|
<span>{t('qctx.exportFilteredAdifFull')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onExportCabrilloSelected && (
|
{onExportCabrilloSelected && (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||||||
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App';
|
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App';
|
||||||
import { rstOptions, type RSTLists } from '@/lib/rst';
|
import { rstOptions, stepRST, type RSTLists } from '@/lib/rst';
|
||||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
@@ -22,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { flagURL } from '@/lib/flags';
|
import { flagURL } from '@/lib/flags';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { bandForMHz } from '@/lib/bandplan';
|
||||||
import { titleCase, sentenceCase } from '@/lib/textCase';
|
import { titleCase, sentenceCase } from '@/lib/textCase';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
|
||||||
@@ -41,9 +42,14 @@ function pfxOf(call: string): string {
|
|||||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||||
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
||||||
// label holds an i18n key (resolved with t() at render time).
|
// label holds an i18n key (resolved with t() at render time).
|
||||||
|
// V is in the list because the log HOLDS it: a LoTW download writes "verified"
|
||||||
|
// rather than "yes". A dropdown without it renders a verified contact as blank,
|
||||||
|
// which reads as "nothing recorded" — and the operator's next click would replace
|
||||||
|
// the strongest confirmation they have with whatever they picked instead.
|
||||||
const QSL_STATUSES = [
|
const QSL_STATUSES = [
|
||||||
{ value: '_', label: 'qedit.qslDash' },
|
{ value: '_', label: 'qedit.qslDash' },
|
||||||
{ value: 'Y', label: 'qedit.qslYes' },
|
{ value: 'Y', label: 'qedit.qslYes' },
|
||||||
|
{ value: 'V', label: 'qedit.qslVerified' },
|
||||||
{ value: 'N', label: 'qedit.qslNo' },
|
{ value: 'N', label: 'qedit.qslNo' },
|
||||||
{ value: 'R', label: 'qedit.qslRequested' },
|
{ value: 'R', label: 'qedit.qslRequested' },
|
||||||
{ value: 'I', label: 'qedit.qslIgnore' },
|
{ value: 'I', label: 'qedit.qslIgnore' },
|
||||||
@@ -57,14 +63,16 @@ type ConfDef = {
|
|||||||
key: string; label: string;
|
key: string; label: string;
|
||||||
sent?: keyof QSOForm; rcvd?: keyof QSOForm;
|
sent?: keyof QSOForm; rcvd?: keyof QSOForm;
|
||||||
sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm;
|
sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm;
|
||||||
via?: keyof QSOForm;
|
// How the card travelled, each way (ADIF QSL_SENT_VIA / QSL_RCVD_VIA). Paper
|
||||||
|
// only — the electronic channels below ARE the route.
|
||||||
|
sentVia?: keyof QSOForm; rcvdVia?: keyof QSOForm;
|
||||||
};
|
};
|
||||||
const CONFIRMATIONS: ConfDef[] = [
|
const CONFIRMATIONS: ConfDef[] = [
|
||||||
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', via: 'qsl_via' },
|
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', sentVia: 'qsl_sent_via', rcvdVia: 'qsl_rcvd_via' },
|
||||||
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
||||||
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
||||||
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
||||||
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
|
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any, rcvd: 'clublog_qso_download_status' as any, rcvdDate: 'clublog_qso_download_date' as any },
|
||||||
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
||||||
];
|
];
|
||||||
// i18n label keys for confirmation channels whose label has translatable words
|
// i18n label keys for confirmation channels whose label has translatable words
|
||||||
@@ -73,11 +81,46 @@ const CONF_LABEL_KEYS: Record<string, string> = {
|
|||||||
QSL: 'qedit.confQslPaper',
|
QSL: 'qedit.confQslPaper',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reading order for the channel list: the two that carry an ARRL award first —
|
||||||
|
// paper QSL and LoTW — then everything else alphabetically, so a channel is
|
||||||
|
// found by its name rather than by remembering the order it was added in.
|
||||||
|
const CONF_FIRST: Record<string, number> = { QSL: 0, LOTW: 1 };
|
||||||
|
function confOrder<T extends { key: string; label: string }>(rows: T[]): T[] {
|
||||||
|
return rows.slice().sort((a, b) => {
|
||||||
|
const ra = CONF_FIRST[a.key] ?? 2;
|
||||||
|
const rb = CONF_FIRST[b.key] ?? 2;
|
||||||
|
if (ra !== rb) return ra - rb;
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
||||||
// columns and this channel is backed by ADIF extras — but it still belongs in
|
// columns and this channel is backed by ADIF extras — but it still belongs in
|
||||||
// the channel picker and the status table alongside the rest.
|
// the channel picker and the status table alongside the rest.
|
||||||
const OPSLOG_CONF = 'OPSLOG';
|
const OPSLOG_CONF = 'OPSLOG';
|
||||||
|
|
||||||
|
// HAMLOG.online. Out of CONFIRMATIONS for the same reason as the OpsLog card —
|
||||||
|
// it is stored in the ADIF extras, not in QSO columns — but it is an upload
|
||||||
|
// service like QRZ.com or Club Log, so it gets the ordinary sent/received/date
|
||||||
|
// editor rather than a special one. The received key is THEIR field name, the
|
||||||
|
// one their ADIF export writes; see award.HamlogQSLKey.
|
||||||
|
const HAMLOG_CONF = 'HAMLOG';
|
||||||
|
const HAMLOG_KEYS = {
|
||||||
|
sent: 'APP_OPSLOG_HAMLOG_SENT',
|
||||||
|
sentDate: 'APP_OPSLOG_HAMLOG_SENT_DATE',
|
||||||
|
rcvd: 'APP_HAMLOG_QSO_CFM',
|
||||||
|
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
|
||||||
|
};
|
||||||
|
|
||||||
|
// HamQTH — extras again (ADIF names no HamQTH field), and SENT only: the site
|
||||||
|
// publishes no confirmation feed, so a received column here would be a promise
|
||||||
|
// nothing can keep.
|
||||||
|
const HAMQTH_CONF = 'HAMQTH';
|
||||||
|
const HAMQTH_KEYS = {
|
||||||
|
sent: 'APP_OPSLOG_HAMQTH_SENT',
|
||||||
|
sentDate: 'APP_OPSLOG_HAMQTH_SENT_DATE',
|
||||||
|
};
|
||||||
|
|
||||||
// Colour-coded status cell for the confirmation grid.
|
// Colour-coded status cell for the confirmation grid.
|
||||||
function StatusCell({ value }: { value?: string }) {
|
function StatusCell({ value }: { value?: string }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -97,9 +140,14 @@ function StatusCell({ value }: { value?: string }) {
|
|||||||
// every row; painting that orange (as it used to be, in the
|
// every row; painting that orange (as it used to be, in the
|
||||||
// same orange as Requested) made the table shout about a
|
// same orange as Requested) made the table shout about a
|
||||||
// non-problem and told you nothing apart.
|
// non-problem and told you nothing apart.
|
||||||
|
// Verified green — ADIF's V: confirmed AND validated by the awarding body.
|
||||||
|
// It is what a LoTW download writes, and this table used
|
||||||
|
// to fall through to "No" for it — reporting a verified
|
||||||
|
// contact as unconfirmed, in the one place an operator
|
||||||
|
// goes to check.
|
||||||
// Ignore dashed — deliberately excluded, on purpose.
|
// Ignore dashed — deliberately excluded, on purpose.
|
||||||
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
const label = v === 'Y' ? t('qedit.qslYes') : v === 'V' ? t('qedit.qslVerified') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
||||||
const cls = v === 'Y' ? 'bg-success text-success-foreground border border-success'
|
const cls = v === 'Y' || v === 'V' ? 'bg-success text-success-foreground border border-success'
|
||||||
: v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
|
: v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
|
||||||
: v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
|
: v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
|
||||||
: v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
|
: v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
|
||||||
@@ -216,6 +264,28 @@ function QslSelect({ value, onChange }: { value?: string; onChange: (v: string)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ADIF QSL Via enumeration. M (manager) is import-only in the standard, so
|
||||||
|
// it is not offered here — a file that arrives carrying it keeps it, and this
|
||||||
|
// list is what an operator may choose.
|
||||||
|
const QSL_VIA_CHOICES = [
|
||||||
|
{ value: '_', label: 'qedit.qslDash' },
|
||||||
|
{ value: 'B', label: 'qedit.viaBureau' },
|
||||||
|
{ value: 'D', label: 'qedit.viaDirect' },
|
||||||
|
{ value: 'E', label: 'qedit.viaElectronic' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function QslViaSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{QSL_VIA_CHOICES.map((s) => <SelectItem key={s.value} value={s.value}>{t(s.label)}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
|
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
|
||||||
@@ -243,6 +313,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
const splitHz = (hz?: number) => hz
|
const splitHz = (hz?: number) => hz
|
||||||
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
||||||
: { khz: '', hz: '' };
|
: { khz: '', hz: '' };
|
||||||
|
// Correcting a frequency corrects its band. The pair has to agree — the log,
|
||||||
|
// every award and every upload are read on the BAND — and an operator fixing
|
||||||
|
// a wrong frequency is not also expecting to fix the band by hand, which is
|
||||||
|
// exactly how a QSO ends up filed on 20m at 7 MHz.
|
||||||
|
//
|
||||||
|
// Only when the number lands in a known allocation: half a frequency is typed
|
||||||
|
// on the way to all of it, and a band must never be blanked by that.
|
||||||
|
const syncBand = (khz: string, hz: string, field: 'band' | 'band_rx') => {
|
||||||
|
if (!khz.trim()) return;
|
||||||
|
const b = bandForMHz((parseInt(khz, 10) * 1000 + (parseInt(hz, 10) || 0)) / 1_000_000);
|
||||||
|
if (b) set(field, b as any);
|
||||||
|
};
|
||||||
|
|
||||||
const f0 = splitHz(draft.freq_hz);
|
const f0 = splitHz(draft.freq_hz);
|
||||||
const fr0 = splitHz(draft.freq_rx_hz);
|
const fr0 = splitHz(draft.freq_rx_hz);
|
||||||
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
||||||
@@ -340,7 +423,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
// refreshes it. A cached answer from a thinner QRZ subscription (or any
|
// refreshes it. A cached answer from a thinner QRZ subscription (or any
|
||||||
// stale row) otherwise stayed for its whole 30-day life and the button
|
// stale row) otherwise stayed for its whole 30-day life and the button
|
||||||
// appeared to do nothing.
|
// appeared to do nothing.
|
||||||
const r: any = await LookupCallsignFresh(call);
|
// The QSO's OWN date: a ClubLog exception is resolved as of when the
|
||||||
|
// contact happened, not as of today. Re-looking-up a DXpedition contact
|
||||||
|
// months later must not move it to whatever the prefix means now.
|
||||||
|
const r: any = await LookupCallsignFresh(call, (dateOn || '').slice(0, 10));
|
||||||
// The lookup WINS over what is in the record — that is the point of asking
|
// The lookup WINS over what is in the record — that is the point of asking
|
||||||
// for it. But an EMPTY result must never blank a good value: `??` only
|
// for it. But an EMPTY result must never blank a good value: `??` only
|
||||||
// guards against null, and Go marshals an unset string as "", so a QRZ
|
// guards against null, and Go marshals an unset string as "", so a QRZ
|
||||||
@@ -456,7 +542,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
<DialogContent overlayBlur={false} className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||||
<DialogHeader className="flex-row items-baseline gap-2">
|
<DialogHeader className="flex-row items-baseline gap-2">
|
||||||
<DialogTitle>{t('qedit.title')}</DialogTitle>
|
<DialogTitle>{t('qedit.title')}</DialogTitle>
|
||||||
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
||||||
@@ -512,9 +598,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-20"><Label>S</Label>
|
<div className="flex flex-col w-20"><Label>S</Label>
|
||||||
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div>
|
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType
|
||||||
|
onChange={(v) => set('rst_sent', v)}
|
||||||
|
onWheelStep={(d) => set('rst_sent', stepRST(draft.rst_sent ?? '', d, draft.mode ?? ''))} /></div>
|
||||||
<div className="flex flex-col w-20"><Label>R</Label>
|
<div className="flex flex-col w-20"><Label>R</Label>
|
||||||
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType
|
||||||
|
onChange={(v) => set('rst_rcvd', v)}
|
||||||
|
onWheelStep={(d) => set('rst_rcvd', stepRST(draft.rst_rcvd ?? '', d, draft.mode ?? ''))} /></div>
|
||||||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||||
title={t('qedit.fetchTitle')}>
|
title={t('qedit.fetchTitle')}>
|
||||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||||
@@ -562,13 +652,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
|
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
|
||||||
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
<Input value={freqKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqKHz(v); syncBand(v, freqHz, 'band'); }} className="font-mono w-24" placeholder="kHz" />
|
||||||
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
<Input value={freqHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqHz(v); syncBand(freqKHz, v, 'band'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
|
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
|
||||||
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
<Input value={freqRxKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxKHz(v); syncBand(v, freqRxHz, 'band_rx'); }} className="font-mono w-24" placeholder="kHz" />
|
||||||
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
<Input value={freqRxHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxHz(v); syncBand(freqRxKHz, v, 'band_rx'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -589,6 +679,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||||
|
<div className="flex flex-col w-24"><Label>{t('qedit.gridExt')}</Label><Input title={t('qedit.gridExtTip')} value={draft.gridsquare_ext ?? ''} onChange={(e) => set('gridsquare_ext', e.target.value)} className="font-mono uppercase" /></div>
|
||||||
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)}
|
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)}
|
||||||
@@ -633,7 +724,12 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<Label>{t('qedit.qslMsg')}</Label>
|
<Label>{t('qedit.qslMsg')}</Label>
|
||||||
<Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} />
|
<Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
|
{/* The manager, and only the manager — ADIF QSL_VIA. How the
|
||||||
|
card travelled is a separate field, on the QSL Info tab. */}
|
||||||
|
<div>
|
||||||
|
<Label>{t('qedit.qslVia')}</Label>
|
||||||
|
<Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} placeholder={t('qedit.qslViaPlaceholder')} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -672,6 +768,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
|
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
|
||||||
const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
|
const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
|
||||||
const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
|
const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
|
||||||
|
// The extras-backed channel reads and writes the same way, one
|
||||||
|
// level down. Save merges the extras rather than replacing them,
|
||||||
|
// so an emptied field keeps its stored value — which is why the
|
||||||
|
// statuses are written as explicit Y/N and never removed.
|
||||||
|
const exVal = (k: string) => String(draft.extras?.[k] ?? '');
|
||||||
|
const exPut = (k: string, v: any) => {
|
||||||
|
const next = { ...(draft.extras ?? {}) };
|
||||||
|
next[k] = String(v ?? '');
|
||||||
|
set('extras', next as any);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
{/* Left: edit one confirmation channel at a time */}
|
{/* Left: edit one confirmation channel at a time */}
|
||||||
@@ -681,18 +787,46 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<Select value={confSel} onValueChange={setConfSel}>
|
<Select value={confSel} onValueChange={setConfSel}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}
|
{confOrder([
|
||||||
{/* Listed here but NOT in CONFIRMATIONS: that table maps
|
...CONFIRMATIONS.map((c) => ({ key: c.key, label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label })),
|
||||||
|
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog') },
|
||||||
|
{ key: HAMLOG_CONF, label: 'HAMLOG.online' },
|
||||||
|
{ key: HAMQTH_CONF, label: 'HamQTH' },
|
||||||
|
]).map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}
|
||||||
|
{/* The three above that are NOT in CONFIRMATIONS — the
|
||||||
QSO columns, and this channel lives in the ADIF
|
QSO columns, and this channel lives in the ADIF
|
||||||
extras. It gets its own editor below rather than the
|
OpsLog card, HAMLOG.online and HamQTH — are backed by
|
||||||
generic sent/received/date grid, which has no field
|
ADIF extras rather than QSO columns, and each gets its
|
||||||
to bind to. */}
|
own editor below instead of the generic
|
||||||
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
|
sent/received/date grid, which has no field to bind
|
||||||
|
to. */}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{confSel === OPSLOG_CONF ? (
|
{confSel === HAMQTH_CONF ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMQTH_KEYS.sent)} onChange={(v) => exPut(HAMQTH_KEYS.sent, v)} /></div>
|
||||||
|
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMQTH_KEYS.sentDate)} onChange={(v) => exPut(HAMQTH_KEYS.sentDate, v)} /></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : confSel === HAMLOG_CONF ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
||||||
|
<div><Label>{t('qedit.received')}</Label><QslSelect value={exVal(HAMLOG_KEYS.rcvd)} onChange={(v) => exPut(HAMLOG_KEYS.rcvd, v)} /></div>
|
||||||
|
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMLOG_KEYS.sentDate)} onChange={(v) => exPut(HAMLOG_KEYS.sentDate, v)} /></div>
|
||||||
|
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={exVal(HAMLOG_KEYS.rcvdDate)} onChange={(v) => exPut(HAMLOG_KEYS.rcvdDate, v)} /></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : confSel === OPSLOG_CONF ? (
|
||||||
/* OpsLog's own card. "Sent" is stamped when the card
|
/* OpsLog's own card. "Sent" is stamped when the card
|
||||||
actually goes out, so it is shown, not offered: ticking
|
actually goes out, so it is shown, not offered: ticking
|
||||||
it by hand would record something that never happened.
|
it by hand would record something that never happened.
|
||||||
@@ -734,8 +868,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</div>
|
</div>
|
||||||
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
|
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
|
||||||
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
|
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
|
||||||
{def.via && (
|
{/* How the card travelled, each way — ADIF's QSL Via
|
||||||
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
enumeration. Not the manager: that is a callsign
|
||||||
|
and lives on the Contact's details tab. */}
|
||||||
|
{def.sentVia && (
|
||||||
|
<div><Label>{t('qedit.sentVia')}</Label><QslViaSelect value={val(def.sentVia)} onChange={(v) => put(def.sentVia, v)} /></div>
|
||||||
|
)}
|
||||||
|
{def.rcvdVia && (
|
||||||
|
<div><Label>{t('qedit.rcvdVia')}</Label><QslViaSelect value={val(def.rcvdVia)} onChange={(v) => put(def.rcvdVia, v)} /></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
@@ -760,24 +900,32 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{CONFIRMATIONS.map((c) => (
|
{/* The extras-backed channels (OpsLog card,
|
||||||
|
HAMLOG.online, HamQTH) sit in the same ordered list
|
||||||
|
as the column-backed ones: the reader is looking for
|
||||||
|
a name, not for a storage detail. A dash in RECEIVED
|
||||||
|
means the channel has nothing to receive — Club Log
|
||||||
|
and HamQTH publish no confirmations — which is not
|
||||||
|
the same statement as "N". */}
|
||||||
|
{confOrder([
|
||||||
|
...CONFIRMATIONS.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label,
|
||||||
|
sent: val(c.sent),
|
||||||
|
rcvd: c.rcvd ? val(c.rcvd) : null,
|
||||||
|
})),
|
||||||
|
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog'), sent: opslogQslSent ? 'Y' : 'N', rcvd: qslReceived ? 'Y' : 'N' },
|
||||||
|
{ key: HAMLOG_CONF, label: 'HAMLOG.online', sent: exVal(HAMLOG_KEYS.sent), rcvd: exVal(HAMLOG_KEYS.rcvd) },
|
||||||
|
{ key: HAMQTH_CONF, label: 'HamQTH', sent: exVal(HAMQTH_KEYS.sent), rcvd: null },
|
||||||
|
]).map((c) => (
|
||||||
<tr key={c.key} className="text-xs">
|
<tr key={c.key} className="text-xs">
|
||||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{c.label}</td>
|
||||||
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
<td className="w-24"><StatusCell value={c.sent} /></td>
|
||||||
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
<td className="w-24">{c.rcvd === null
|
||||||
|
? <span className="block text-center text-[11px] text-muted-foreground">—</span>
|
||||||
|
: <StatusCell value={c.rcvd} />}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{/* OpsLog's own card, read from the ADIF extras rather
|
|
||||||
than a QSO column — hence a hand-written row instead
|
|
||||||
of a CONFIRMATIONS entry. "Sent" is stamped by OpsLog
|
|
||||||
when the card actually goes out, so it stays
|
|
||||||
read-only here: an operator ticking it by hand would
|
|
||||||
be recording something that never happened. */}
|
|
||||||
<tr className="text-xs">
|
|
||||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{t('qedit.confOpsLog')}</td>
|
|
||||||
<td className="w-24"><StatusCell value={opslogQslSent ? 'Y' : 'N'} /></td>
|
|
||||||
<td className="w-24"><StatusCell value={qslReceived ? 'Y' : 'N'} /></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -819,7 +967,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<F label={t('qedit.stationCallsign')} span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
|
<F label={t('qedit.stationCallsign')} span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
|
||||||
<F label={t('qedit.operator')} span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
<F label={t('qedit.operator')} span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
||||||
<F label={t('qedit.myGrid')}><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
<F label={t('qedit.myGrid')}><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
||||||
<F label={t('qedit.gridExt')}><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
<F label={t('qedit.myGridExt')}><Input title={t('qedit.gridExtTip')} value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
||||||
<F label={t('qedit.country')} span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder={t('qedit.country')} onChange={(v) => set('my_country', v)} /></F>
|
<F label={t('qedit.country')} span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder={t('qedit.country')} onChange={(v) => set('my_country', v)} /></F>
|
||||||
<F label={t('qedit.state')}><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
<F label={t('qedit.state')}><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
||||||
<F label={t('qedit.county')}><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
|
<F label={t('qedit.county')}><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
|
||||||
@@ -855,6 +1003,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.powerWeather')}</p>
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.powerWeather')}</p>
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<div className="grid grid-cols-6 gap-3">
|
||||||
|
{/* YOUR power first, then theirs. tx_pwr was saved by this
|
||||||
|
editor but had no field: a contact logged at the wrong
|
||||||
|
power could be read back and never corrected. */}
|
||||||
|
<F label={t('qedit.txPower')}><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||||
<F label={t('qedit.rxPower')}><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
<F label={t('qedit.rxPower')}><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||||
<F label={t('qedit.distance')}><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
<F label={t('qedit.distance')}><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
||||||
<F label={t('qedit.aIndex')}><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
<F label={t('qedit.aIndex')}><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat }
|
|||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
@@ -18,6 +19,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
||||||
|
import { rowStyleFor, type RowColorSettings } from '@/lib/rowColors';
|
||||||
|
|
||||||
// Register every Community feature once. v32+ requires explicit registration;
|
// Register every Community feature once. v32+ requires explicit registration;
|
||||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||||
@@ -63,13 +65,16 @@ type Props = {
|
|||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
onExportSelectedFull?: (ids: number[]) => void;
|
||||||
onExportSelectedFields?: (ids: number[]) => void;
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
|
onExportFilteredFull?: () => void;
|
||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
@@ -80,6 +85,8 @@ type Props = {
|
|||||||
// One column per defined award; the cell shows the reference this QSO counts
|
// One column per defined award; the cell shows the reference this QSO counts
|
||||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||||
awardCols?: { code: string; name: string }[];
|
awardCols?: { code: string; name: string }[];
|
||||||
|
// Whole-row colouring by QSL / LoTW status (Settings → Appearance).
|
||||||
|
rowColors?: RowColorSettings | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
||||||
@@ -132,6 +139,10 @@ function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
|
|||||||
|
|
||||||
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||||
// ── QSO basics ──
|
// ── QSO basics ──
|
||||||
|
// Position in the log, oldest = 1. Filled by the backend over the WHOLE log,
|
||||||
|
// so it does not change when a filter narrows what is shown.
|
||||||
|
{ group: 'QSO', label: t('rqg.c.number'), colId: 'number', headerName: t('rqg.h.number'), field: 'number' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
|
comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||||
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||||
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||||
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
|
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
|
||||||
@@ -167,8 +178,9 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||||
// Derived, not stored: computed from the two locations at display time, like
|
// Derived, not stored: computed from the two locations at display time, like
|
||||||
// the cluster grid's own distance column.
|
// the cluster grid's own distance column.
|
||||||
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono',
|
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km',
|
||||||
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
|
headerName: t('rqg.h.distance_km') + ' (' + distanceUnit() + ')', width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
|
valueGetter: (p) => { const km = qsoDistanceKm(p.data, myGrid); return km ? distanceValue(km) : km; },
|
||||||
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||||
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
||||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||||
@@ -179,6 +191,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
||||||
|
{ group: 'QSL', label: t('rqg.c.qsl_sent_via'), colId: 'qsl_sent_via', headerName: t('rqg.h.qsl_sent_via'), field: 'qsl_sent_via' as any, width: 110 },
|
||||||
|
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_via'), colId: 'qsl_rcvd_via', headerName: t('rqg.h.qsl_rcvd_via'), field: 'qsl_rcvd_via' as any, width: 110 },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 },
|
{ group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 },
|
||||||
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
||||||
|
|
||||||
@@ -197,6 +211,17 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||||
// App-specific: operator marked a QSL as RECEIVED for this QSO (drives PSE/TNX).
|
// App-specific: operator marked a QSL as RECEIVED for this QSO (drives PSE/TNX).
|
||||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_rcvd'), colId: 'opslog_qsl_card_rcvd', headerName: t('rqg.c.opslog_qsl_card_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_QSL_RCVD'] ? 'Y' : 'N'; }, defaultVisible: false },
|
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_rcvd'), colId: 'opslog_qsl_card_rcvd', headerName: t('rqg.c.opslog_qsl_card_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_QSL_RCVD'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||||
|
// HAMLOG.online. No promoted column — the ADIF standard names a field for
|
||||||
|
// hamlog.EU and none for hamlog.ONLINE — so these read the extras, exactly
|
||||||
|
// like the OpsLog card columns above. Hidden by default: a column per
|
||||||
|
// service, shown to everyone, is how a grid becomes unreadable.
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamlog_sent'), colId: 'hamlog_sent', headerName: t('rqg.h.hamlog_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMLOG_SENT'] || 'N'; }, defaultVisible: false },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
||||||
|
// HamQTH, the extras again — sent only, the site having no confirmations.
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamqth_sent'), colId: 'hamqth_sent', headerName: t('rqg.h.hamqth_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMQTH_SENT'] || 'N'; }, defaultVisible: false },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamqth_sent_date'), colId: 'hamqth_sent_date', headerName: t('rqg.h.hamqth_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMQTH_SENT_DATE']), defaultVisible: false },
|
||||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||||
|
|
||||||
@@ -213,6 +238,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd'), colId: 'clublog_qso_download_status', headerName: t('rqg.c.clublog_rcvd'), field: 'clublog_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd_date'), colId: 'clublog_qso_download_date', headerName: t('rqg.h.clublog_rcvd_date'), field: 'clublog_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
|
||||||
// ── Contest ──
|
// ── Contest ──
|
||||||
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
|
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
|
||||||
@@ -299,7 +326,7 @@ const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
|
|||||||
return rest;
|
return rest;
|
||||||
});
|
});
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFull, onExportSelectedFields, onExportFiltered, onExportFilteredFull, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
@@ -315,7 +342,9 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
// inside the column definitions, so nothing else would notice.
|
// inside the column definitions, so nothing else would notice.
|
||||||
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||||
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
const [distUnit, setDistUnit] = useState(distanceUnit);
|
||||||
|
useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []);
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt, distUnit]);
|
||||||
|
|
||||||
// Right-click: if the clicked row isn't already part of the selection,
|
// Right-click: if the clicked row isn't already part of the selection,
|
||||||
// select just it; then open the bulk-action menu on the whole selection.
|
// select just it; then open the bulk-action menu on the whole selection.
|
||||||
@@ -621,6 +650,20 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
api.setColumnsVisible(ids, false);
|
api.setColumnsVisible(ids, false);
|
||||||
saveColumnState();
|
saveColumnState();
|
||||||
}
|
}
|
||||||
|
// Whole-dialog show/hide. showAll/hideAll cover the CATALOGUE only; the award
|
||||||
|
// columns live in their own persisted code set, so a global that skipped them
|
||||||
|
// would leave a row of columns behind and look broken.
|
||||||
|
function setEverything(visible: boolean) {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
api.setColumnsVisible(COL_CATALOG.map((c) => c.colId!), visible);
|
||||||
|
if (awardCols && awardCols.length > 0) {
|
||||||
|
persistAwardShown(visible ? new Set(awardCols.map((a) => a.code.toUpperCase())) : new Set());
|
||||||
|
api.setColumnsVisible(awardCols.map((a) => `award_${a.code}`), visible);
|
||||||
|
}
|
||||||
|
saveColumnState();
|
||||||
|
}
|
||||||
|
|
||||||
function resetDefaults() {
|
function resetDefaults() {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
@@ -696,23 +739,31 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
animateRows={false}
|
animateRows={false}
|
||||||
suppressCellFocus
|
suppressCellFocus
|
||||||
getRowId={(p) => String((p.data as any).id)}
|
getRowId={(p) => String((p.data as any).id)}
|
||||||
|
getRowStyle={(p) => rowStyleFor(p.data, rowColors ?? null, p.node.rowIndex ?? undefined)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Every handler is passed STRAIGHT through, never wrapped in an arrow: a
|
||||||
|
wrapper is always truthy, so the menu drew "Fix country" and "Update
|
||||||
|
from the callsign databases" even in a grid that wired neither — and
|
||||||
|
clicking them did nothing at all. */}
|
||||||
<QSOContextMenu
|
<QSOContextMenu
|
||||||
menu={menu}
|
menu={menu}
|
||||||
onClose={() => setMenu(null)}
|
onClose={() => setMenu(null)}
|
||||||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
onUpdateFromCty={onUpdateFromCty}
|
||||||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
onUpdateFromQRZ={onUpdateFromQRZ}
|
||||||
onUpdateFromClublog={onUpdateFromClublog}
|
onUpdateFromClublog={onUpdateFromClublog}
|
||||||
|
onUpdateCountyFromULS={onUpdateCountyFromULS}
|
||||||
onSendTo={onSendTo}
|
onSendTo={onSendTo}
|
||||||
onSendRecording={onSendRecording}
|
onSendRecording={onSendRecording}
|
||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
onBulkEdit={onBulkEdit}
|
onBulkEdit={onBulkEdit}
|
||||||
onExportSelected={onExportSelected}
|
onExportSelected={onExportSelected}
|
||||||
|
onExportSelectedFull={onExportSelectedFull}
|
||||||
onExportSelectedFields={onExportSelectedFields}
|
onExportSelectedFields={onExportSelectedFields}
|
||||||
onExportFiltered={onExportFiltered}
|
onExportFiltered={onExportFiltered}
|
||||||
|
onExportFilteredFull={onExportFilteredFull}
|
||||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||||
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@@ -726,9 +777,23 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
{t('rqg.pickerDesc')}
|
{t('rqg.pickerDesc')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{/* Whole-dialog controls. The per-group links alone meant hiding
|
||||||
|
everything was one click PER GROUP, and there are a dozen. */}
|
||||||
|
<div className="flex items-center gap-1 px-5 text-[11px] text-muted-foreground">
|
||||||
|
<span>{t('rqg.pickerAllGroups')}</span>
|
||||||
|
<button type="button" className="text-[11px] text-primary hover:underline px-1"
|
||||||
|
onClick={() => setEverything(true)}>{t('rqg.all')}</button>
|
||||||
|
<span className="opacity-40">·</span>
|
||||||
|
<button type="button" className="text-[11px] text-muted-foreground hover:underline px-1"
|
||||||
|
onClick={() => setEverything(false)}>{t('rqg.none')}</button>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||||
{GROUP_ORDER.map((group) => {
|
{GROUP_ORDER.map((group) => {
|
||||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
// Alphabetical within the group: the catalog's order is the
|
||||||
|
// GRID's column order, which appends newcomers at the end — so
|
||||||
|
// the two ClubLog rows sat at opposite ends of the Uploads box.
|
||||||
|
const cols = COL_CATALOG.filter((c) => c.group === group)
|
||||||
|
.slice().sort((a, b) => (a.label ?? '').localeCompare(b.label ?? ''));
|
||||||
if (cols.length === 0) return null;
|
if (cols.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={group} className="rounded-md border border-border p-2.5">
|
<div key={group} className="rounded-md border border-border p-2.5">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
|||||||
|
// RotorCompassClassic — the original rotor dial, kept as an option.
|
||||||
|
//
|
||||||
|
// Settings → Rotator chooses between this and the current compass. It was
|
||||||
|
// replaced rather than removed because the two answer the same question in
|
||||||
|
// different ways: this one is a small light-map dial with the quick turns in a
|
||||||
|
// column beside it, and an operator used to it should not have to relearn a
|
||||||
|
// panel to keep working.
|
||||||
|
//
|
||||||
|
// An azimuthal-equidistant rotor display (à la 4O3A RotorGenius).
|
||||||
|
//
|
||||||
|
// A world map centred on the operator's QTH (north up) fills the dial, ringed by
|
||||||
|
// a green azimuth bezel. A green needle shows the antenna heading (two needles
|
||||||
|
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
|
||||||
|
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
|
||||||
|
// to turn the antenna there.
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
|
||||||
|
import { feature } from 'topojson-client';
|
||||||
|
import landTopo from 'world-atlas/land-110m.json';
|
||||||
|
import { Compass, X, Play, Square } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// Decode the coastline outline once (≈110 m simplified land polygons).
|
||||||
|
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
|
||||||
|
const GRATICULE = geoGraticule10();
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bearing?: number | null; // short-path azimuth to DX (deg)
|
||||||
|
headings: number[]; // radiating heading(s) — rotor + Ultrabeam pattern
|
||||||
|
boomHeading?: number | null; // mechanical boom (rotor) azimuth, shown grey when it differs
|
||||||
|
pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge)
|
||||||
|
centerLat?: number | null; // operator latitude (projection centre)
|
||||||
|
centerLon?: number | null; // operator longitude
|
||||||
|
rotorEnabled?: boolean;
|
||||||
|
rotors?: string[]; // logical rotor names; >1 → show a selector
|
||||||
|
activeRotor?: number; // index of the selected rotor (0-based)
|
||||||
|
onSelectRotor?: (i: number) => void; // switch the active rotor
|
||||||
|
onGoto?: (az: number) => void; // click-to-turn
|
||||||
|
onClose?: () => void;
|
||||||
|
// Quick-turn buttons and the azimuth box, shown only where the caller wants
|
||||||
|
// them: Station Control draws its own GoTo/Stop around this compass, and two
|
||||||
|
// sets of the same controls side by side would be nothing but confusing.
|
||||||
|
presets?: { label: string; azimuth: number }[];
|
||||||
|
onStop?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE = 168;
|
||||||
|
const C = SIZE / 2;
|
||||||
|
const R = C - 6; // outer bezel radius
|
||||||
|
const MAP_R = R - 6; // map/clip radius (inside the bezel)
|
||||||
|
|
||||||
|
function pt(az: number, radius: number): [number, number] {
|
||||||
|
const a = ((az - 90) * Math.PI) / 180;
|
||||||
|
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RotorCompassClassic({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
// Raw text, not a number: binding the input to a normalised value makes
|
||||||
|
// Backspace fight the operator on the way from "230" to "23". It is parsed
|
||||||
|
// when it is sent, and only then.
|
||||||
|
const [azText, setAzText] = useState('');
|
||||||
|
const showControls = !!(presets || onStop);
|
||||||
|
|
||||||
|
// Which preset was just pressed, so it can light up for a moment.
|
||||||
|
//
|
||||||
|
// A rotor takes seconds to start moving and the needle barely twitches at
|
||||||
|
// first, so without this the only answer to "did that register?" is to press
|
||||||
|
// it again — which is how an antenna ends up ordered somewhere twice. The
|
||||||
|
// acknowledgement has to come from the button itself, at once.
|
||||||
|
const [flashIdx, setFlashIdx] = useState<number | null>(null);
|
||||||
|
const flashTimer = useRef<number | undefined>(undefined);
|
||||||
|
useEffect(() => () => window.clearTimeout(flashTimer.current), []);
|
||||||
|
const pressPreset = (i: number, az: number) => {
|
||||||
|
if (!onGoto) return;
|
||||||
|
setFlashIdx(i);
|
||||||
|
window.clearTimeout(flashTimer.current);
|
||||||
|
flashTimer.current = window.setTimeout(() => setFlashIdx(null), 450);
|
||||||
|
onGoto(az);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stop needs the same acknowledgement, for the same reason and one more: it
|
||||||
|
// is pressed when something is already wrong, and a button that stays inert
|
||||||
|
// gets hit again and again. Its own flag, so stopping does not blank a preset
|
||||||
|
// that is still lit.
|
||||||
|
const [stopFlash, setStopFlash] = useState(false);
|
||||||
|
const stopTimer = useRef<number | undefined>(undefined);
|
||||||
|
useEffect(() => () => window.clearTimeout(stopTimer.current), []);
|
||||||
|
const pressStop = () => {
|
||||||
|
if (!onStop) return;
|
||||||
|
setStopFlash(true);
|
||||||
|
window.clearTimeout(stopTimer.current);
|
||||||
|
stopTimer.current = window.setTimeout(() => setStopFlash(false), 450);
|
||||||
|
onStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
|
||||||
|
// almost always a typo for 36 or 306, and a rotor swinging through north on a
|
||||||
|
// slip of the finger is worth one rejected keypress.
|
||||||
|
const sendAz = () => {
|
||||||
|
const s = azText.trim();
|
||||||
|
if (s === '' || !onGoto) return;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return;
|
||||||
|
onGoto(n);
|
||||||
|
setAzText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardinals = useMemo(
|
||||||
|
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
||||||
|
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Project the world centred on the QTH (north up; antipode at the bezel).
|
||||||
|
const { land, grat } = useMemo(() => {
|
||||||
|
if (centerLat == null || centerLon == null) return { land: '', grat: '' };
|
||||||
|
const proj = geoAzimuthalEquidistant()
|
||||||
|
.rotate([-centerLon, -centerLat])
|
||||||
|
.clipAngle(179.9)
|
||||||
|
.scale(MAP_R / Math.PI)
|
||||||
|
.translate([C, C]);
|
||||||
|
const path = geoPath(proj as any);
|
||||||
|
return { land: path(LAND as any) || '', grat: path(GRATICULE as any) || '' };
|
||||||
|
}, [centerLat, centerLon]);
|
||||||
|
|
||||||
|
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
|
||||||
|
if (!onGoto) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * SIZE - C;
|
||||||
|
const y = ((e.clientY - rect.top) / rect.height) * SIZE - C;
|
||||||
|
let az = (Math.atan2(y, x) * 180) / Math.PI + 90;
|
||||||
|
az = ((az % 360) + 360) % 360;
|
||||||
|
onGoto(Math.round(az));
|
||||||
|
}
|
||||||
|
|
||||||
|
const headLabel = headings.length ? headings[0] : null;
|
||||||
|
|
||||||
|
// Short and long path to the DX, in figures.
|
||||||
|
//
|
||||||
|
// The bezel already carries the short path as a red marker, but a marker is a
|
||||||
|
// direction, not a number — the same pair sits in the status bar at 10px and
|
||||||
|
// operators reported not being able to read it. It lives here because it
|
||||||
|
// belongs to the compass: every place that draws one gets the readout, instead
|
||||||
|
// of each caller inventing its own. Clickable when the caller can turn, like
|
||||||
|
// the status bar's. Built as a value because it is placed in one of two
|
||||||
|
// columns depending on whether the controls are shown.
|
||||||
|
const pathReadout = (
|
||||||
|
<div className="flex gap-1.5 mt-2 font-mono w-full">
|
||||||
|
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
|
||||||
|
<button key={lbl} type="button" disabled={az == null || !onGoto}
|
||||||
|
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
|
||||||
|
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
|
||||||
|
className={
|
||||||
|
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors active:scale-95 ' +
|
||||||
|
(az == null
|
||||||
|
? 'border-border text-muted-foreground/50 cursor-not-allowed'
|
||||||
|
: onGoto
|
||||||
|
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
|
||||||
|
: 'border-border text-muted-foreground cursor-default')
|
||||||
|
}>
|
||||||
|
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
{/* Header — matches the WinKeyer / Voice keyer panels. */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||||
|
<Compass className="size-4 text-primary shrink-0" />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
|
||||||
|
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
||||||
|
<div className="flex-1" />
|
||||||
|
{pattern && (
|
||||||
|
<span
|
||||||
|
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
|
||||||
|
pattern === 'reverse' ? 'bg-warning-muted text-warning-muted-foreground'
|
||||||
|
: pattern === 'bi' ? 'bg-info-muted text-info-muted-foreground'
|
||||||
|
: 'bg-success-muted text-success-muted-foreground')}
|
||||||
|
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
|
||||||
|
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
|
||||||
|
: 'Ultrabeam normal'}>
|
||||||
|
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-sm font-bold text-success tabular-nums">
|
||||||
|
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
||||||
|
</span>
|
||||||
|
{onClose && (
|
||||||
|
<button className="text-muted-foreground hover:text-foreground" title="Hide rotor" onClick={onClose}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Multiple rotors — pick which one the dial shows and turns. */}
|
||||||
|
{rotors && rotors.length > 1 && (
|
||||||
|
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
|
||||||
|
{rotors.map((nm, i) => {
|
||||||
|
const active = (activeRotor ?? 0) === i;
|
||||||
|
const label = nm?.trim() || `Rotor ${i + 1}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSelectRotor?.(i)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-success text-success-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/70',
|
||||||
|
)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dial on the left, controls on the right. The controls column is sized to
|
||||||
|
fit WITHIN the dial's height: the widget sits in a row whose height is
|
||||||
|
set by the entry strip, so it may grow sideways but never downwards. */}
|
||||||
|
<div className="flex items-start gap-2 p-2 min-h-0">
|
||||||
|
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
|
||||||
|
sibling of the <svg> landed beside it. */}
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-0 shrink-0">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
|
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
||||||
|
style={{ width: SIZE, height: SIZE }}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="rotorDial"><circle cx={C} cy={C} r={MAP_R} /></clipPath>
|
||||||
|
</defs>
|
||||||
|
{/* water + world map, clipped to the dial */}
|
||||||
|
<circle cx={C} cy={C} r={MAP_R} fill="#d3e7f1" />
|
||||||
|
<g clipPath="url(#rotorDial)">
|
||||||
|
{grat && <path d={grat} fill="none" stroke="#9cc0d6" strokeWidth={0.4} opacity={0.7} />}
|
||||||
|
{land && <path d={land} fill="#dfe2cf" stroke="#9aa589" strokeWidth={0.4} />}
|
||||||
|
</g>
|
||||||
|
{/* green azimuth bezel */}
|
||||||
|
<circle cx={C} cy={C} r={R} fill="none" stroke="#16a34a" strokeWidth={5} />
|
||||||
|
|
||||||
|
{/* ticks every 10°, longer at 30° */}
|
||||||
|
{Array.from({ length: 36 }, (_, i) => i * 10).map((d) => {
|
||||||
|
const major = d % 30 === 0;
|
||||||
|
const [x1, y1] = pt(d, MAP_R);
|
||||||
|
const [x2, y2] = pt(d, MAP_R - (major ? 7 : 4));
|
||||||
|
return <line key={d} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#475569" strokeWidth={major ? 1 : 0.6} opacity={0.7} />;
|
||||||
|
})}
|
||||||
|
{/* cardinal labels + degree numbers at 45° */}
|
||||||
|
{cardinals.map(({ d, l }) => {
|
||||||
|
const [x, y] = pt(d, MAP_R - 13);
|
||||||
|
return <text key={l} x={x} y={y} textAnchor="middle" dominantBaseline="central" className="fill-slate-700" style={{ fontSize: l.length > 1 ? 7 : 9, fontWeight: 700 }}>{l}</text>;
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* DX short-path bearing → small red marker on the bezel */}
|
||||||
|
{bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return (
|
||||||
|
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
|
||||||
|
); })()}
|
||||||
|
|
||||||
|
{/* mechanical boom (rotor) heading — grey dashed needle, shown when the
|
||||||
|
Ultrabeam radiates somewhere other than the boom (reverse/bi) so the
|
||||||
|
operator sees where the antenna physically points vs where it boom-sits */}
|
||||||
|
{boomHeading != null && pattern && pattern !== 'normal' && (() => {
|
||||||
|
const [x, y] = pt(boomHeading, MAP_R - 2);
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<title>Boom (rotor) {Math.round(boomHeading)}°</title>
|
||||||
|
<line x1={C} y1={C} x2={x} y2={y} stroke="#64748b" strokeWidth={2} strokeDasharray="3 3" strokeLinecap="round" />
|
||||||
|
<circle cx={x} cy={y} r={3} fill="#64748b" stroke="#fff" strokeWidth={1} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* radiating heading needle(s) — green; two when bidirectional */}
|
||||||
|
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
|
||||||
|
<g key={i}>
|
||||||
|
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
|
||||||
|
<polygon points={`${x},${y} ${pt(h - 5, MAP_R - 12).join(',')} ${pt(h + 5, MAP_R - 12).join(',')}`} fill="#15803d" opacity={i === 0 ? 1 : 0.55} />
|
||||||
|
</g>
|
||||||
|
); })}
|
||||||
|
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* With the controls column present the readout goes at the FOOT OF IT
|
||||||
|
instead: the dial sets the widget's height, the controls are shorter
|
||||||
|
than the dial, and that leftover space is exactly the right size for
|
||||||
|
the pair. Under the dial it would push the whole widget taller. */}
|
||||||
|
{!showControls && pathReadout}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick turns + free azimuth + Stop. */}
|
||||||
|
{showControls && (
|
||||||
|
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||||
|
{/* Two columns so six regions fit beside the dial rather than under
|
||||||
|
it. An operator with fewer keeps the same compact block. */}
|
||||||
|
{!!presets?.length && (
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{presets.map((p, i) => (
|
||||||
|
<button
|
||||||
|
key={`${p.label}-${i}`}
|
||||||
|
type="button"
|
||||||
|
disabled={!onGoto}
|
||||||
|
onClick={() => pressPreset(i, p.azimuth)}
|
||||||
|
title={`${p.label} — ${p.azimuth}°`}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border py-1 text-xs font-semibold truncate transition-all duration-150 active:scale-95',
|
||||||
|
flashIdx === i
|
||||||
|
// Lit, and showing the azimuth it just sent: the label alone
|
||||||
|
// would only say the press landed, not what was ordered.
|
||||||
|
? 'border-success bg-success text-success-foreground scale-95'
|
||||||
|
: 'border-border bg-muted/40',
|
||||||
|
onGoto && flashIdx !== i ? 'hover:bg-muted' : '',
|
||||||
|
!onGoto ? 'opacity-50 cursor-not-allowed' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{flashIdx === i ? `${p.azimuth}°` : p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Free azimuth: Enter sends, so the whole thing is type-three-digits
|
||||||
|
-and-go without reaching for the mouse. */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={azText}
|
||||||
|
onChange={(e) => setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }}
|
||||||
|
placeholder={t('rotor.azPh')}
|
||||||
|
title={t('rotor.azTitle')}
|
||||||
|
disabled={!onGoto}
|
||||||
|
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={sendAz}
|
||||||
|
disabled={!onGoto || azText.trim() === ''}
|
||||||
|
title={t('rotor.go')}
|
||||||
|
className="flex items-center gap-1 rounded-md border border-success/60 bg-success-muted px-2 py-1 text-xs font-bold text-success-muted-foreground transition-transform hover:bg-success/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Play className="size-3" /> {t('rotor.go')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onStop && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={pressStop}
|
||||||
|
title={t('rotor.stop')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-1.5 rounded-md border py-1 text-xs font-bold transition-all duration-150 active:scale-95',
|
||||||
|
stopFlash
|
||||||
|
// Solid, not a tint: STOP reads the same in both languages, so
|
||||||
|
// the fill is the whole acknowledgement.
|
||||||
|
? 'border-destructive bg-destructive text-destructive-foreground scale-95'
|
||||||
|
: 'border-destructive/60 bg-destructive/15 text-destructive hover:bg-destructive/25',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Square className="size-3 fill-current" /> {t('rotor.stop')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* mt-auto: the readout sits at the FOOT of the column, level with the
|
||||||
|
bottom of the dial, instead of floating under the Stop button. */}
|
||||||
|
<div className="mt-auto">{pathReadout}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,791 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
||||||
|
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
|
||||||
|
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { gridToLatLon, splitAtAntimeridian } from '@/lib/maidenhead';
|
||||||
|
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
||||||
|
import { loadMapView, saveMapView } from '@/lib/mapView';
|
||||||
|
import { loadMapBase, saveMapBase, MAP_BASE_SAT } from '@/lib/mapBase';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { SkyPlot, type SkyPoint } from '@/components/SkyPlot';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// Satellites — where the birds are, and what to do with the radio.
|
||||||
|
//
|
||||||
|
// The tab answers the three questions a pass poses, and answers them where they
|
||||||
|
// are asked: how long have I got (the countdown), where is it (the map), what
|
||||||
|
// do I tune (the readout). Everything else — which satellites to follow, where
|
||||||
|
// the elements come from, the rotator — is maintenance and lives in Settings.
|
||||||
|
// During a pass there is no time to configure anything.
|
||||||
|
|
||||||
|
type Bird = {
|
||||||
|
name: string; norad: number; geostationary: boolean; favorite: boolean;
|
||||||
|
has_elements: boolean; element_name: string; epoch_age_h: number;
|
||||||
|
transponders?: {
|
||||||
|
label: string; mode: string; down_lo: number; down_hi: number;
|
||||||
|
up_lo: number; up_hi: number; inverting: boolean; ctcss: number; linear: boolean;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
type Position = {
|
||||||
|
name: string; lat: number; lon: number; alt_km: number; footprint_km: number;
|
||||||
|
az: number; el: number; range_km: number; range_rate: number;
|
||||||
|
};
|
||||||
|
type Pass = {
|
||||||
|
name: string; aos: string; los: string; aos_az: number; los_az: number;
|
||||||
|
max_el: number; max_el_az: number; max_el_at: string; duration_s: number;
|
||||||
|
};
|
||||||
|
type PassInfo = {
|
||||||
|
name: string; has_pass: boolean; in_pass: boolean;
|
||||||
|
aos: string; los: string; aos_az: number; los_az: number;
|
||||||
|
max_el: number; max_el_az: number; max_el_at: string; duration_s: number;
|
||||||
|
};
|
||||||
|
type Tuning = {
|
||||||
|
name: string; transponder: string; mode: string;
|
||||||
|
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
|
||||||
|
ctcss: number; inverting: boolean;
|
||||||
|
az: number; el: number; range_km: number; range_rate: number; visible: boolean;
|
||||||
|
lat: number; lon: number; alt_km: number; footprint_km: number;
|
||||||
|
};
|
||||||
|
type Track = {
|
||||||
|
on: boolean; name: string; transponder: string; mode: string;
|
||||||
|
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
|
||||||
|
az: number; el: number; visible: boolean;
|
||||||
|
radio: string; // "sat" | "downlink-only" | ""
|
||||||
|
error: string;
|
||||||
|
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAP_VIEW_SAT = 'opslog.satMapView';
|
||||||
|
|
||||||
|
// The readout column. Wide enough by default to hold a frequency to the hertz
|
||||||
|
// without wrapping, and adjustable because how much map an operator wants
|
||||||
|
// against how much detail is theirs to decide — a station watching a footprint
|
||||||
|
// cross an ocean wants the map, one working a pass wants the numbers.
|
||||||
|
const SIDE_W_KEY = 'opslog.satSideWidth';
|
||||||
|
const SIDE_SHOWN_KEY = 'opslog.satSideShown';
|
||||||
|
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
|
||||||
|
const SKY_SHOWN_KEY = 'opslog.satSkyShown';
|
||||||
|
|
||||||
|
const fmtHz = (hz: number) => {
|
||||||
|
if (!hz) return '—';
|
||||||
|
// Six decimals: a linear transponder is tuned to the hundred hertz, and the
|
||||||
|
// Doppler correction moves the last three digits every second.
|
||||||
|
return (hz / 1e6).toFixed(6).replace(/(\d)(?=(\d{3})+\.)/g, '$1 ');
|
||||||
|
};
|
||||||
|
const fmtDeg = (d: number) => `${d.toFixed(1)}°`;
|
||||||
|
const fmtKm = (km: number) => `${Math.round(km).toLocaleString()} km`;
|
||||||
|
const hhmm = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? '—' : d.toISOString().slice(11, 16);
|
||||||
|
};
|
||||||
|
const hhmmss = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? '—' : d.toISOString().slice(11, 19);
|
||||||
|
};
|
||||||
|
const inMin = (iso: string) => Math.round((Date.parse(iso) - Date.now()) / 60000);
|
||||||
|
|
||||||
|
// A countdown an operator can act on. Seconds while they matter, then minutes,
|
||||||
|
// then hours — nobody needs "1h 04m 37s", and nobody wants "0m" for the last
|
||||||
|
// fifty seconds before a satellite rises.
|
||||||
|
function fmtCountdown(ms: number): string {
|
||||||
|
const s = Math.max(0, Math.round(ms / 1000));
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
return `${h}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The eight points of the compass, for an azimuth an operator reads rather than
|
||||||
|
// computes. "rises at 213°" is a number; "rises SW" is a direction to look in.
|
||||||
|
const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||||
|
const compass = (deg: number) => COMPASS[Math.round(((deg % 360) + 360) % 360 / 45) % 8];
|
||||||
|
|
||||||
|
// How good a pass is, as a colour. A bird 70° overhead and one scraping 12°
|
||||||
|
// along the horizon are not the same evening, and the table should say so
|
||||||
|
// without the operator reading every number.
|
||||||
|
function elClass(el: number): string {
|
||||||
|
if (el >= 50) return 'text-success';
|
||||||
|
if (el >= 25) return 'text-foreground';
|
||||||
|
if (el >= 15) return 'text-caution';
|
||||||
|
return 'text-muted-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mode a satellite is worked in, as a dot: FM and SSB call for a completely
|
||||||
|
// different set-up, and which of the two the next pass is decides whether the
|
||||||
|
// operator reaches for a handheld or the whole station.
|
||||||
|
const MODE_COLOUR: Record<string, string> = {
|
||||||
|
FM: 'var(--info)',
|
||||||
|
SSB: 'var(--success)',
|
||||||
|
CW: 'var(--caution)',
|
||||||
|
DATA: 'var(--warning)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ModeDot({ mode }: { mode: string }) {
|
||||||
|
const colour = MODE_COLOUR[mode];
|
||||||
|
if (!colour) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={mode}
|
||||||
|
className="ml-1.5 inline-block size-1.5 rounded-full align-middle"
|
||||||
|
style={{ backgroundColor: colour }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [birds, setBirds] = useState<Bird[]>([]);
|
||||||
|
const [sel, setSel] = useState<string>(() => localStorage.getItem('opslog.satSelected') || '');
|
||||||
|
const [tpIdx, setTpIdx] = useState(0);
|
||||||
|
const [positions, setPositions] = useState<Position[]>([]);
|
||||||
|
const [passes, setPasses] = useState<Pass[]>([]);
|
||||||
|
const [tuning, setTuning] = useState<Tuning | null>(null);
|
||||||
|
const [pass, setPass] = useState<PassInfo | null>(null);
|
||||||
|
const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
|
||||||
|
const [tracking, setTracking] = useState<Track | null>(null);
|
||||||
|
const [sky, setSky] = useState<SkyPoint[]>([]);
|
||||||
|
const [skyShown, setSkyShown] = useState(() => localStorage.getItem(SKY_SHOWN_KEY) !== '0');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
// A clock of its own, so every countdown on the panel ticks from one instant
|
||||||
|
// and none of them needs a round trip to Go to lose a second.
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// What the operator follows. Chosen in Settings; following nothing means
|
||||||
|
// every satellite we can both find and tune, which is what somebody who has
|
||||||
|
// not chosen yet should see.
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const favs = birds.filter((b) => b.favorite);
|
||||||
|
if (favs.length > 0) return favs;
|
||||||
|
return birds.filter((b) => b.has_elements && (b.transponders?.length ?? 0) > 0);
|
||||||
|
}, [birds]);
|
||||||
|
const bird = useMemo(() => birds.find((b) => b.name === sel) ?? null, [birds, sel]);
|
||||||
|
const tp = bird?.transponders?.[tpIdx] ?? null;
|
||||||
|
// The mode each satellite is worked in, for the pass table's dot. Its first
|
||||||
|
// transponder: on a bird that has two, the first is the one it is known for.
|
||||||
|
const modeOf = useMemo(() => {
|
||||||
|
const m = new Map(birds.map((b) => [b.name, b.transponders?.[0]?.mode ?? ''] as const));
|
||||||
|
return (name: string) => m.get(name) ?? '';
|
||||||
|
}, [birds]);
|
||||||
|
|
||||||
|
// ── Data ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const loadBirds = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list: Bird[] = (await GetSatelliteBirds()) as any;
|
||||||
|
setBirds(list ?? []);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTle = useCallback(async () => {
|
||||||
|
try { setTle((await GetSatelliteTLEInfo()) as any); } catch { /* shown as unknown */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPasses = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setPasses(((await GetSatellitePasses([], 0)) as any) ?? []);
|
||||||
|
setErr('');
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadBirds(); loadTle(); loadPasses(); }, [loadBirds, loadTle, loadPasses]);
|
||||||
|
useEffect(() => EventsOn('sat:tle', () => { loadTle(); loadBirds(); loadPasses(); }), [loadTle, loadBirds, loadPasses]);
|
||||||
|
useEffect(() => { if (sel) localStorage.setItem('opslog.satSelected', sel); }, [sel]);
|
||||||
|
useEffect(() => { setTpIdx(0); }, [sel]);
|
||||||
|
|
||||||
|
// Keep the selection inside what is followed: an operator who narrows the list
|
||||||
|
// in Settings must not be left looking at a satellite that is no longer there.
|
||||||
|
useEffect(() => {
|
||||||
|
if (shown.length === 0) return;
|
||||||
|
if (!sel || !shown.some((b) => b.name === sel)) setSel(shown[0].name);
|
||||||
|
}, [shown, sel]);
|
||||||
|
|
||||||
|
// The map's satellites, every five seconds: a low orbit moves about a third of
|
||||||
|
// a degree of longitude in that time, which is a pixel or two at this zoom.
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const p: Position[] = ((await GetSatellitePositions([])) as any) ?? [];
|
||||||
|
if (live) setPositions(p);
|
||||||
|
} catch { /* a missing locator is already reported by the passes call */ }
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 5000);
|
||||||
|
return () => { live = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The readout, every second: this is the number an operator types into a
|
||||||
|
// radio, and a Doppler correction on 70 cm moves by a few tens of hertz a
|
||||||
|
// second at the middle of a pass.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sel) { setTuning(null); return; }
|
||||||
|
let live = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const tn: Tuning = (await GetSatelliteTuning(sel, tpIdx, 0)) as any;
|
||||||
|
if (live) setTuning(tn);
|
||||||
|
} catch { if (live) setTuning(null); }
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1000);
|
||||||
|
return () => { live = false; window.clearInterval(id); };
|
||||||
|
}, [sel, tpIdx]);
|
||||||
|
|
||||||
|
// The pass, every twenty seconds. Predicting one steps the orbit across hours;
|
||||||
|
// the countdown itself is two timestamps and a clock, which the browser runs.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sel) { setPass(null); return; }
|
||||||
|
let live = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const p: PassInfo = (await GetSatelliteNextPass(sel)) as any;
|
||||||
|
if (live) setPass(p);
|
||||||
|
} catch { if (live) setPass(null); }
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 20_000);
|
||||||
|
return () => { live = false; window.clearInterval(id); };
|
||||||
|
}, [sel]);
|
||||||
|
|
||||||
|
// The pass drawn across the sky. Once a minute is plenty: the SHAPE of a
|
||||||
|
// pass does not change while it happens — only the marker on it moves, and
|
||||||
|
// that comes from the tuning poll a second at a time.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sel || !skyShown) { setSky([]); return; }
|
||||||
|
let live = true;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const pts: SkyPoint[] = ((await GetSatelliteSkyTrack(sel, 120)) as any) ?? [];
|
||||||
|
if (live) setSky(pts);
|
||||||
|
} catch { if (live) setSky([]); }
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 60_000);
|
||||||
|
return () => { live = false; window.clearInterval(id); };
|
||||||
|
}, [sel, skyShown]);
|
||||||
|
useEffect(() => { writeUiPref(SKY_SHOWN_KEY, skyShown ? '1' : '0'); }, [skyShown]);
|
||||||
|
|
||||||
|
// Passes are cheap but not free, and they change slowly.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(loadPasses, 5 * 60_000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [loadPasses]);
|
||||||
|
|
||||||
|
// The tracker's own state, pushed as it moves. Polled as well, at a lazy
|
||||||
|
// rate, so a panel opened while tracking is already running is not blank
|
||||||
|
// until the next tick.
|
||||||
|
useEffect(() => {
|
||||||
|
const read = async () => {
|
||||||
|
try { setTracking((await GetSatelliteTracking()) as any); } catch { /* not tracking */ }
|
||||||
|
};
|
||||||
|
read();
|
||||||
|
const off = EventsOn('sat:track', (s: any) => setTracking(s ?? null));
|
||||||
|
const id = window.setInterval(read, 10_000);
|
||||||
|
return () => { off(); window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTracking = async () => {
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
if (tracking?.on) {
|
||||||
|
await StopSatelliteTracking();
|
||||||
|
setTracking(null);
|
||||||
|
} else {
|
||||||
|
await StartSatelliteTracking(sel, tpIdx);
|
||||||
|
setTracking((await GetSatelliteTracking()) as any);
|
||||||
|
}
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Map ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||||
|
const baseRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const [basemap, setBasemap] = useState<BasemapKey>(() => loadMapBase(MAP_BASE_SAT, 'light'));
|
||||||
|
const saved = useRef(loadMapView(MAP_VIEW_SAT));
|
||||||
|
const [track, setTrack] = useState<Position[]>([]);
|
||||||
|
|
||||||
|
const home = useMemo(() => gridToLatLon(myGrid), [myGrid]);
|
||||||
|
|
||||||
|
// ── The readout column ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [sideW, setSideW] = useState<number>(() => {
|
||||||
|
const n = parseFloat(localStorage.getItem(SIDE_W_KEY) || '');
|
||||||
|
return Number.isFinite(n) && n >= SIDE_W_MIN && n <= SIDE_W_MAX ? n : SIDE_W_DEFAULT;
|
||||||
|
});
|
||||||
|
const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0');
|
||||||
|
useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]);
|
||||||
|
useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]);
|
||||||
|
|
||||||
|
// Dragging the grip. Measured from where the pointer STARTED rather than from
|
||||||
|
// the container, and with the pointer captured — without the capture the map
|
||||||
|
// underneath swallows the moves the instant the cursor crosses it.
|
||||||
|
const startSideDrag = (e: React.PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
const x0 = e.clientX;
|
||||||
|
const w0 = sideW;
|
||||||
|
const onMove = (ev: PointerEvent) => {
|
||||||
|
setSideW(Math.min(SIDE_W_MAX, Math.max(SIDE_W_MIN, Math.round(w0 + (x0 - ev.clientX)))));
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!divRef.current || mapRef.current) return;
|
||||||
|
const m = L.map(divRef.current, {
|
||||||
|
zoomControl: true, attributionControl: true,
|
||||||
|
worldCopyJump: false, preferCanvas: true,
|
||||||
|
// Opened on the station, not on the Atlantic: the passes that matter are
|
||||||
|
// the ones over the operator's own head.
|
||||||
|
center: saved.current ? [saved.current.lat, saved.current.lon] : [home?.lat ?? 25, home?.lon ?? 0],
|
||||||
|
zoom: saved.current ? saved.current.zoom : 3,
|
||||||
|
minZoom: 2,
|
||||||
|
});
|
||||||
|
m.on('moveend', () => {
|
||||||
|
const c = m.getCenter();
|
||||||
|
saveMapView(MAP_VIEW_SAT, c.lat, c.lng, m.getZoom());
|
||||||
|
});
|
||||||
|
mapRef.current = m;
|
||||||
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
|
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
|
||||||
|
ro.observe(divRef.current);
|
||||||
|
const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(settle);
|
||||||
|
ro.disconnect();
|
||||||
|
m.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
layerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [home?.lat, home?.lon]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const m = mapRef.current;
|
||||||
|
if (!m) return;
|
||||||
|
baseRef.current?.remove(); labelsRef.current?.remove();
|
||||||
|
const bm = BASEMAPS[basemap];
|
||||||
|
const opts: L.TileLayerOptions = {
|
||||||
|
maxNativeZoom: bm.maxNativeZoom, noWrap: true,
|
||||||
|
bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
|
||||||
|
};
|
||||||
|
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||||
|
saveMapBase(MAP_BASE_SAT, basemap);
|
||||||
|
}, [basemap]);
|
||||||
|
|
||||||
|
// The selected bird's path over the ground, redrawn when the selection
|
||||||
|
// changes and every couple of minutes as it walks off the front of it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sel) { setTrack([]); return; }
|
||||||
|
let live = true;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const pts: Position[] = ((await GetSatelliteGroundTrack(sel, 100)) as any) ?? [];
|
||||||
|
if (live) setTrack(pts);
|
||||||
|
} catch { if (live) setTrack([]); }
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 120_000);
|
||||||
|
return () => { live = false; window.clearInterval(id); };
|
||||||
|
}, [sel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = layerRef.current;
|
||||||
|
if (!layer) return;
|
||||||
|
layer.clearLayers();
|
||||||
|
if (home) {
|
||||||
|
L.circleMarker([home.lat, home.lon], {
|
||||||
|
radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
|
||||||
|
}).bindTooltip(myGrid, { direction: 'top' }).addTo(layer);
|
||||||
|
}
|
||||||
|
if (track.length > 1) {
|
||||||
|
const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number]));
|
||||||
|
L.polyline(pts as L.LatLngExpression[][], {
|
||||||
|
color: 'var(--info)', weight: 1.2, opacity: 0.7, dashArray: '4 4', smoothFactor: 0,
|
||||||
|
}).addTo(layer);
|
||||||
|
}
|
||||||
|
const wanted = new Set(shown.map((b) => b.name));
|
||||||
|
for (const p of positions) {
|
||||||
|
if (!wanted.has(p.name) && p.name !== sel) continue;
|
||||||
|
const chosen = p.name === sel;
|
||||||
|
const up = p.el > 0;
|
||||||
|
const colour = chosen ? '#22c55e' : up ? '#f59e0b' : '#9ca3af';
|
||||||
|
// The footprint is the honest answer to "can I hear it": everything inside
|
||||||
|
// the circle has the satellite above its horizon.
|
||||||
|
L.circle([p.lat, p.lon], {
|
||||||
|
radius: p.footprint_km * 1000,
|
||||||
|
color: colour, weight: chosen ? 1.2 : 0.8, opacity: chosen ? 0.7 : 0.35,
|
||||||
|
fillColor: colour, fillOpacity: chosen ? 0.1 : 0.05,
|
||||||
|
}).addTo(layer);
|
||||||
|
L.circleMarker([p.lat, p.lon], {
|
||||||
|
radius: chosen ? 6 : 4, color: '#fff', weight: 1,
|
||||||
|
fillColor: colour, fillOpacity: 1,
|
||||||
|
})
|
||||||
|
.bindTooltip(`${p.name} · ${fmtDeg(p.el)} · ${Math.round(p.alt_km)} km`, { direction: 'top' })
|
||||||
|
.on('click', () => setSel(p.name))
|
||||||
|
.addTo(layer);
|
||||||
|
}
|
||||||
|
}, [positions, track, home?.lat, home?.lon, myGrid, sel, shown]);
|
||||||
|
|
||||||
|
// ── Render ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// The pass, as a countdown and a bar. Both derived here from two timestamps,
|
||||||
|
// so they move every second without asking Go anything.
|
||||||
|
const aosMs = pass?.has_pass ? Date.parse(pass.aos) : 0;
|
||||||
|
const losMs = pass?.has_pass ? Date.parse(pass.los) : 0;
|
||||||
|
const inPass = !!pass?.has_pass && now >= aosMs && now < losMs;
|
||||||
|
const progress = inPass && losMs > aosMs ? (now - aosMs) / (losMs - aosMs) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0 gap-1 p-1">
|
||||||
|
{/* Header: what to work, and the one button that touches the radio.
|
||||||
|
Everything else about satellites — which ones, where the elements come
|
||||||
|
from, the rotator — is in Settings, because none of it is something to
|
||||||
|
do while a bird is going over. */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap px-1 shrink-0">
|
||||||
|
<SatIcon className="size-4 text-muted-foreground" />
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-md border border-border bg-background px-2 text-xs min-w-[12rem]"
|
||||||
|
value={sel}
|
||||||
|
onChange={(e) => setSel(e.target.value)}
|
||||||
|
>
|
||||||
|
{shown.length === 0 && <option value="">{t('sat.noneFollowed')}</option>}
|
||||||
|
{shown.map((b) => (
|
||||||
|
<option key={b.name} value={b.name} disabled={!b.has_elements && !b.geostationary}>
|
||||||
|
{b.name}{b.has_elements || b.geostationary ? '' : ` — ${t('sat.noElements')}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(bird?.transponders?.length ?? 0) > 1 && (
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-md border border-border bg-background px-2 text-xs"
|
||||||
|
value={tpIdx}
|
||||||
|
onChange={(e) => setTpIdx(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{bird!.transponders!.map((x, i) => (
|
||||||
|
<option key={i} value={i}>{x.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={tracking?.on ? 'default' : 'outline'}
|
||||||
|
className={cn('h-7 px-2 gap-1.5', tracking?.on && 'bg-success text-background hover:bg-success/90')}
|
||||||
|
onClick={toggleTracking}
|
||||||
|
disabled={!bird?.transponders?.length}
|
||||||
|
title={tracking?.on
|
||||||
|
? (tracking.radio === 'sat' ? t('sat.trackingFull') : t('sat.trackingDown'))
|
||||||
|
: t('sat.trackTip')}
|
||||||
|
>
|
||||||
|
<Radio className="size-3.5" />
|
||||||
|
{tracking?.on ? t('sat.tracking') : t('sat.track')}
|
||||||
|
</Button>
|
||||||
|
{tracking?.on && tracking.radio === 'downlink-only' && (
|
||||||
|
<span className="text-[11px] text-warning">{t('sat.downlinkOnly')}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{/* Elements are maintenance, so only their AGE is here — and only when
|
||||||
|
it has become a reason the panel might be wrong. */}
|
||||||
|
{tle?.stale && <span className="text-[11px] text-warning">{t('sat.tleStale')}</span>}
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-md border border-border bg-background px-2 text-xs"
|
||||||
|
value={basemap}
|
||||||
|
onChange={(e) => setBasemap(e.target.value as BasemapKey)}
|
||||||
|
>
|
||||||
|
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="sm"
|
||||||
|
className={cn('h-7 px-1.5', skyShown && 'text-success')}
|
||||||
|
onClick={() => setSkyShown((v) => !v)}
|
||||||
|
title={skyShown ? t('sat.hideSky') : t('sat.showSky')}
|
||||||
|
>
|
||||||
|
<Radar className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
{/* Put the whole window on the map. On a laptop the readout takes a
|
||||||
|
third of the screen, and there are moments — watching a footprint
|
||||||
|
cross an ocean — when the map IS the answer. */}
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="sm" className="h-7 px-1.5"
|
||||||
|
onClick={() => setSideShown((v) => !v)}
|
||||||
|
title={sideShown ? t('sat.hideSide') : t('sat.showSide')}
|
||||||
|
>
|
||||||
|
{sideShown ? <PanelRightClose className="size-3.5" /> : <PanelRightOpen className="size-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}
|
||||||
|
|
||||||
|
<div className="flex gap-1 flex-1 min-h-0">
|
||||||
|
{/* The map. isolate is load-bearing, not tidiness: Leaflet stacks its
|
||||||
|
own panes and controls up to z-index 1000, which without a stacking
|
||||||
|
context of their own float over Preferences and every dialog in the
|
||||||
|
app — the map ends up on top of the very buttons that would close
|
||||||
|
it. */}
|
||||||
|
<div className="relative isolate z-0 flex-1 min-w-0 rounded-lg overflow-hidden border border-border">
|
||||||
|
<div ref={divRef} className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sideShown && (
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
title={t('sat.sideWidthTip')}
|
||||||
|
onPointerDown={startSideDrag}
|
||||||
|
onDoubleClick={() => setSideW(SIDE_W_DEFAULT)}
|
||||||
|
className="group relative shrink-0 w-2 cursor-col-resize flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span className="h-10 w-[3px] rounded-full bg-border group-hover:bg-primary transition-colors" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn('shrink-0 flex flex-col gap-1 min-h-0', !sideShown && 'hidden')}
|
||||||
|
style={{ width: sideW }}>
|
||||||
|
{/* The pass. The first thing an operator looks at and the reason they
|
||||||
|
sit down: how long have I got, and how high does it get. */}
|
||||||
|
<div className={cn('rounded-lg border bg-card p-2',
|
||||||
|
inPass ? 'border-success/60' : 'border-border')}>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="font-medium text-sm truncate">{bird?.name ?? '—'}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bird?.geostationary ? (
|
||||||
|
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.geoHint')}</div>
|
||||||
|
) : !pass?.has_pass ? (
|
||||||
|
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.noPassSoon')}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
|
<span className={cn('text-[10px] uppercase tracking-wide',
|
||||||
|
inPass ? 'text-success' : 'text-muted-foreground')}>
|
||||||
|
{inPass ? t('sat.los') : t('sat.aos')}
|
||||||
|
</span>
|
||||||
|
<span className={cn('text-xl font-semibold tabular-nums leading-none',
|
||||||
|
inPass && 'text-success')}>
|
||||||
|
{fmtCountdown((inPass ? losMs : aosMs) - now)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground tabular-nums ml-auto">
|
||||||
|
{hhmmss(inPass ? pass.los : pass.aos)}Z
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Where in the pass we are. A bar because the useful question
|
||||||
|
mid-pass is not the clock but "am I past the peak". */}
|
||||||
|
<div className="mt-1.5 h-1 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div className="h-full bg-success transition-[width] duration-1000 ease-linear"
|
||||||
|
style={{ width: `${Math.round(progress * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
|
||||||
|
<PassBit label={t('sat.rise')} value={`${hhmm(pass.aos)} ${compass(pass.aos_az)}`} />
|
||||||
|
<PassBit label={t('sat.peak')} value={`${Math.round(pass.max_el)}° ${compass(pass.max_el_az)}`}
|
||||||
|
strong={pass.max_el >= 30} />
|
||||||
|
<PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The sky, seen from underneath it: the centre is straight up, the
|
||||||
|
rim is the horizon, north is at the top. One glance says whether
|
||||||
|
the pass comes over the roof or along the treeline. */}
|
||||||
|
{skyShown && (
|
||||||
|
<div className="rounded-lg border border-border bg-card p-2">
|
||||||
|
<SkyPlot
|
||||||
|
track={sky}
|
||||||
|
az={tuning?.az ?? null}
|
||||||
|
el={tuning?.el ?? null}
|
||||||
|
name={bird?.name}
|
||||||
|
visible={!!tuning?.visible}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Where it is, right now. */}
|
||||||
|
<div className="rounded-lg border border-border bg-card p-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
|
||||||
|
sub={tuning ? compass(tuning.az) : ''} />
|
||||||
|
<Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
|
||||||
|
colour={tuning?.visible ? 'var(--success)' : 'var(--muted-foreground)'}
|
||||||
|
sub={tuning ? (tuning.visible ? t('sat.up') : t('sat.below')) : ''} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
|
||||||
|
<PassBit label={t('sat.range')} value={tuning?.range_km ? fmtKm(tuning.range_km) : '—'} />
|
||||||
|
<PassBit label={t('sat.altitude')} value={tuning?.alt_km ? fmtKm(tuning.alt_km) : '—'} />
|
||||||
|
<PassBit label={t('sat.footprint')} value={tuning?.footprint_km ? fmtKm(tuning.footprint_km) : '—'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approaching or receding, which is the sign of the whole Doppler
|
||||||
|
correction and the one number that explains why the frequencies
|
||||||
|
are moving the way they are. */}
|
||||||
|
{!!tuning && !bird?.geostationary && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{tuning.range_rate < 0
|
||||||
|
? <ArrowUp className="size-3 text-success" />
|
||||||
|
: <ArrowDown className="size-3 text-warning" />}
|
||||||
|
<span>{tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')}</span>
|
||||||
|
<span>{Math.abs(tuning.range_rate).toFixed(2)} km/s</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Where the antenna is, beside where the satellite is. The two
|
||||||
|
differing is a rotator still slewing; the two differing for a
|
||||||
|
long time is a rotator that is stuck, and that is worth being
|
||||||
|
able to see without walking outside. */}
|
||||||
|
{tracking?.rot_on && (
|
||||||
|
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
|
||||||
|
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
|
||||||
|
<span className="font-medium">{fmtDeg(tracking.rot_az)} / {fmtDeg(tracking.rot_el)}</span>
|
||||||
|
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* What to tune. */}
|
||||||
|
<div className="rounded-lg border border-border bg-card p-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<FreqRow label={t('sat.down')} hz={tuning?.down_hz ?? 0} nominal={tuning?.nominal_down ?? 0} />
|
||||||
|
<FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground">
|
||||||
|
{!!tp?.mode && <span>{tp.mode}</span>}
|
||||||
|
{!!tp?.ctcss && <span>CTCSS {tp.ctcss.toFixed(1)}</span>}
|
||||||
|
{tp?.inverting && <span>{t('sat.inverting')}</span>}
|
||||||
|
{tp?.linear && <span>{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</span>}
|
||||||
|
{bird?.geostationary && <span>{t('sat.geo')}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* What is coming. */}
|
||||||
|
<div className="rounded-lg border border-border bg-card flex-1 min-h-0 flex flex-col">
|
||||||
|
<div className="px-2 py-1 text-[11px] font-medium text-muted-foreground border-b border-border shrink-0">
|
||||||
|
{t('sat.nextPasses')}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
{passes.length === 0 && (
|
||||||
|
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
|
||||||
|
)}
|
||||||
|
{passes.length > 0 && (
|
||||||
|
// A real table, so the name column takes the width the longest
|
||||||
|
// name needs — "ZHUHAI-1 OVS-1A" was cut to eight characters in
|
||||||
|
// a fixed one — and the rest keeps its columns lined up under
|
||||||
|
// headings that say what the numbers are.
|
||||||
|
<table className="w-full text-[11px] tabular-nums">
|
||||||
|
<thead className="sticky top-0 z-10 bg-card">
|
||||||
|
<tr className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
<th className="text-left font-medium px-2 py-1">{t('sat.thSat')}</th>
|
||||||
|
<th className="text-left font-medium py-1">{t('sat.thAos')}</th>
|
||||||
|
<th className="text-left font-medium py-1">{t('sat.thLos')}</th>
|
||||||
|
<th className="text-right font-medium py-1">{t('sat.thMaxEl')}</th>
|
||||||
|
<th className="text-right font-medium px-2 py-1">{t('sat.thIn')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{passes.map((p, i) => {
|
||||||
|
const aos = Date.parse(p.aos);
|
||||||
|
const running = aos <= now && Date.parse(p.los) > now;
|
||||||
|
const soon = !running && aos - now < 5 * 60_000;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${p.name}-${p.aos}-${i}`}
|
||||||
|
onClick={() => setSel(p.name)}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer hover:bg-accent/50 border-t border-border/40',
|
||||||
|
p.name === sel && 'bg-accent/40',
|
||||||
|
running && 'bg-success/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-2 py-1 whitespace-nowrap">
|
||||||
|
<span className={cn('font-medium', running && 'text-success')}>{p.name}</span>
|
||||||
|
<ModeDot mode={modeOf(p.name)} />
|
||||||
|
</td>
|
||||||
|
<td className="py-1 whitespace-nowrap">{hhmm(p.aos)}</td>
|
||||||
|
<td className="py-1 whitespace-nowrap text-muted-foreground">{hhmm(p.los)}</td>
|
||||||
|
{/* The elevation is the quality of the pass, so it is
|
||||||
|
coloured like one: a 70° pass overhead and a 12°
|
||||||
|
scrape along the horizon are not the same evening. */}
|
||||||
|
<td className={cn('py-1 text-right font-medium', elClass(p.max_el))}>
|
||||||
|
{Math.round(p.max_el)}°
|
||||||
|
</td>
|
||||||
|
<td className={cn('px-2 py-1 text-right whitespace-nowrap',
|
||||||
|
running ? 'text-success font-medium' : soon ? 'text-warning' : 'text-muted-foreground')}>
|
||||||
|
{running ? t('sat.now') : fmtCountdown(aos - now)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Readout({ label, value, colour, sub }: { label: string; value: string; colour?: string; sub?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md bg-muted/40 px-2 py-1">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="text-lg font-semibold tabular-nums leading-tight" style={colour ? { color: colour } : undefined}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{!!sub && <span className="text-[10px] text-muted-foreground">{sub}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PassBit({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground truncate">{label}</div>
|
||||||
|
<div className={cn('truncate', strong && 'font-semibold text-foreground')}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The corrected frequency large, the nominal one small beside it. Showing only
|
||||||
|
// one of them leaves an operator unable to tell a Doppler correction from a
|
||||||
|
// mistuned transponder.
|
||||||
|
function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) {
|
||||||
|
const shift = hz && nominal ? hz - nominal : 0;
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span>
|
||||||
|
{!!shift && (
|
||||||
|
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||||
|
{shift > 0 ? '+' : '−'}{Math.abs(Math.round(shift))} Hz
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,32 @@ export interface RecentSpotQSO {
|
|||||||
freqKHz: number;
|
freqKHz: number;
|
||||||
mode: string;
|
mode: string;
|
||||||
band?: string;
|
band?: string;
|
||||||
|
// Award references logged for this QSO, each one complete and ready to read
|
||||||
|
// on a cluster line ("POTA FR-11553"). Empty for an ordinary contact.
|
||||||
|
refs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPOT_COMMENT_MAX is the DX cluster comment field: 30 characters, and nodes
|
||||||
|
// truncate what does not fit without saying so. Only the comment BUILT here is
|
||||||
|
// held to it — whatever the operator types is their own business.
|
||||||
|
const SPOT_COMMENT_MAX = 30;
|
||||||
|
|
||||||
|
// spotComment puts the award references after the mode, the order a cluster
|
||||||
|
// line is read in: "CW POTA FR-11553".
|
||||||
|
//
|
||||||
|
// A reference goes in whole or not at all. Truncation would leave "CW POTA" or
|
||||||
|
// half a park number on the air, which is worse than saying nothing: a spot
|
||||||
|
// nobody can act on still costs everyone who reads it.
|
||||||
|
export function spotComment(mode: string, refs?: string[]): string {
|
||||||
|
let out = (mode || '').trim();
|
||||||
|
for (const ref of refs ?? []) {
|
||||||
|
const one = ref.trim();
|
||||||
|
if (!one) continue;
|
||||||
|
const next = out ? `${out} ${one}` : one;
|
||||||
|
if (next.length > SPOT_COMMENT_MAX) continue; // a shorter one may still fit
|
||||||
|
out = next;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -23,6 +49,9 @@ interface Props {
|
|||||||
defaultCall: string;
|
defaultCall: string;
|
||||||
defaultFreqKHz: number;
|
defaultFreqKHz: number;
|
||||||
defaultMode: string;
|
defaultMode: string;
|
||||||
|
// Award references on the QSO being entered (or on the last logged one),
|
||||||
|
// appended to the comment after the mode.
|
||||||
|
defaultRefs?: string[];
|
||||||
// Master cluster name, shown so the user knows where the spot goes.
|
// Master cluster name, shown so the user knows where the spot goes.
|
||||||
targetName?: string;
|
targetName?: string;
|
||||||
recent: RecentSpotQSO[];
|
recent: RecentSpotQSO[];
|
||||||
@@ -33,7 +62,7 @@ interface Props {
|
|||||||
// the master cluster: callsign + frequency (kHz) + a free message (defaults
|
// the master cluster: callsign + frequency (kHz) + a free message (defaults
|
||||||
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
|
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
|
||||||
// contact into the form.
|
// contact into the form.
|
||||||
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
|
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, defaultRefs, targetName, recent, onSend }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [call, setCall] = useState('');
|
const [call, setCall] = useState('');
|
||||||
const [freqKHz, setFreqKHz] = useState('');
|
const [freqKHz, setFreqKHz] = useState('');
|
||||||
@@ -48,12 +77,12 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
setCall((defaultCall || '').toUpperCase());
|
setCall((defaultCall || '').toUpperCase());
|
||||||
setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : '');
|
setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : '');
|
||||||
setMessage(defaultMode || '');
|
setMessage(spotComment(defaultMode, defaultRefs));
|
||||||
setError('');
|
setError('');
|
||||||
setOk(false);
|
setOk(false);
|
||||||
// Focus the freq if the call is already known, else the call.
|
// Focus the freq if the call is already known, else the call.
|
||||||
setTimeout(() => callRef.current?.focus(), 50);
|
setTimeout(() => callRef.current?.focus(), 50);
|
||||||
}, [open, defaultCall, defaultFreqKHz, defaultMode]);
|
}, [open, defaultCall, defaultFreqKHz, defaultMode, defaultRefs]);
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
const c = call.trim().toUpperCase();
|
const c = call.trim().toUpperCase();
|
||||||
@@ -77,7 +106,7 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
|||||||
function pick(q: RecentSpotQSO) {
|
function pick(q: RecentSpotQSO) {
|
||||||
setCall(q.callsign.toUpperCase());
|
setCall(q.callsign.toUpperCase());
|
||||||
if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz));
|
if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz));
|
||||||
if (q.mode) setMessage(q.mode);
|
setMessage(spotComment(q.mode, q.refs));
|
||||||
setError('');
|
setError('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user