mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-05 20:17:29 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a7d742b54 | ||
|
|
146d47455f | ||
|
|
a7d99862d9 | ||
|
|
a24acc2bfd | ||
|
|
ad970eec77 |
No files matched your search
@@ -60,8 +60,11 @@ SOFTWARE.
|
||||
|
||||
## Natural Earth
|
||||
|
||||
- Source: https://www.naturalearthdata.com (1:110m countries, 1:10m rivers)
|
||||
- Source: https://www.naturalearthdata.com (1:110m countries, 1:10m admin-0
|
||||
map units, 1:10m rivers)
|
||||
- License: Public domain — https://www.naturalearthdata.com/about/terms-of-use/
|
||||
- Used for: the country/region polygons of the award boundary assets under
|
||||
`feature/map/src/main/assets/awards/`, and the Ural river centerline used
|
||||
for the European/Asiatic Russia split.
|
||||
`feature/map/src/main/assets/awards/` (including the island / microstate
|
||||
DXCC entities — Singapore, Malta, Canary Is., ... — and the Balearic/Canary
|
||||
rings split out of Spain's 1:10m multipolygon), and the Ural river centerline
|
||||
used for the European/Asiatic Russia split.
|
||||
+56
-29
@@ -81,52 +81,79 @@ object AwardCalculator {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The award unit one QSO counts toward for [type] — the match key of the
|
||||
* award boundary assets' region "code" (WAPC -> "GD"/"HK", WAJA -> "34",
|
||||
* WAZ -> "24", WAS -> "CA", DXCC -> "318"). Null when the QSO does not
|
||||
* count toward that award (wrong entity or missing field). VUCC has no
|
||||
* per-QSO key — its units are the store's grid keys — and returns null.
|
||||
*
|
||||
* Single source of truth shared by the progress counters above and the
|
||||
* map's region-detail dialogs, so a tapped region lists exactly the QSOs
|
||||
* that light it up.
|
||||
*/
|
||||
fun awardKey(type: AwardType, q: GridQso): String? = when (type) {
|
||||
AwardType.DXCC -> (q.dxcc ?: prefixEntityCode(q.call))?.toString()
|
||||
AwardType.WAPC -> wapcKey(q)
|
||||
AwardType.WAJA -> if (entityOf(q) == Entity.JAPAN) {
|
||||
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }
|
||||
} else null
|
||||
|
||||
AwardType.WAZ -> q.cqz?.takeIf { it in 1..40 }?.toString()
|
||||
AwardType.WAS -> if (entityOf(q) == Entity.USA) {
|
||||
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }
|
||||
} else null
|
||||
|
||||
AwardType.VUCC -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every confirmed QSO counted for award region [code] of [type], oldest
|
||||
* first. The store keeps one entry per WORKED GRID, so a QSO spanning
|
||||
* several grids arrives as several copies — [distinct] collapses them.
|
||||
* Not scoped by 台址: the non-VUCC awards count (and fill the map)
|
||||
* globally, so their detail lists do too.
|
||||
*/
|
||||
fun regionQsos(
|
||||
qsosByGrid: Map<String, List<GridQso>>,
|
||||
type: AwardType,
|
||||
code: String
|
||||
): List<GridQso> =
|
||||
qsosByGrid.values.flatten()
|
||||
.filter { awardKey(type, it) == code }
|
||||
.distinct()
|
||||
.sortedBy { it.epochMs }
|
||||
|
||||
private fun wapcKey(q: GridQso): String? = when (entityOf(q)) {
|
||||
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }
|
||||
Entity.HONG_KONG -> HK_CODE
|
||||
Entity.MACAO -> MO_CODE
|
||||
Entity.TAIWAN -> TW_CODE
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun calculateDxcc(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
val code = q.dxcc ?: prefixEntityCode(q.call)
|
||||
if (code != null) keys.add(code.toString())
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.DXCC, it) }.toSet()
|
||||
return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC)
|
||||
}
|
||||
|
||||
private fun calculateWapc(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
when (entityOf(q)) {
|
||||
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }?.let { keys.add(it) }
|
||||
Entity.HONG_KONG -> keys.add(HK_CODE)
|
||||
Entity.MACAO -> keys.add(MO_CODE)
|
||||
Entity.TAIWAN -> keys.add(TW_CODE)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.WAPC, it) }.toSet()
|
||||
return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC)
|
||||
}
|
||||
|
||||
private fun calculateWaja(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
if (entityOf(q) != Entity.JAPAN) continue
|
||||
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }?.let { keys.add(it) }
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.WAJA, it) }.toSet()
|
||||
return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA)
|
||||
}
|
||||
|
||||
private fun calculateWaz(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
q.cqz?.takeIf { it in 1..40 }?.let { keys.add(it.toString()) }
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.WAZ, it) }.toSet()
|
||||
return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ)
|
||||
}
|
||||
|
||||
private fun calculateWas(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
if (entityOf(q) != Entity.USA) continue
|
||||
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }?.let { keys.add(it) }
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.WAS, it) }.toSet()
|
||||
return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS)
|
||||
}
|
||||
|
||||
|
||||
+45
@@ -152,4 +152,49 @@ class AwardCalculatorTest {
|
||||
assertEquals(40, progress.first { it.type == AwardType.WAZ }.target)
|
||||
assertEquals(50, progress.first { it.type == AwardType.WAS }.target)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `award key matches the region codes for every award type`() {
|
||||
val cn = qso("BG7XYZ", dxcc = 318, cqz = 24, state = "GD")
|
||||
assertEquals("318", AwardCalculator.awardKey(AwardType.DXCC, cn))
|
||||
assertEquals("GD", AwardCalculator.awardKey(AwardType.WAPC, cn))
|
||||
assertEquals("24", AwardCalculator.awardKey(AwardType.WAZ, cn))
|
||||
// DXCC code falls back to a prefix guess when LoTW omitted the field
|
||||
assertEquals("152", AwardCalculator.awardKey(AwardType.DXCC, qso("XX9A")))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.DXCC, qso("ZS1ABC")))
|
||||
// WAPC special entities
|
||||
assertEquals("HK", AwardCalculator.awardKey(AwardType.WAPC, qso("VR2X", dxcc = 321)))
|
||||
assertEquals("MO", AwardCalculator.awardKey(AwardType.WAPC, qso("XX9A", dxcc = 152)))
|
||||
assertEquals("TW", AwardCalculator.awardKey(AwardType.WAPC, qso("BV2A", dxcc = 386)))
|
||||
// WAJA: only Japanese prefecture numbers count
|
||||
assertEquals("34", AwardCalculator.awardKey(AwardType.WAJA, qso("JH0ABC", dxcc = 339, state = "34")))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.WAJA, qso("BG7XYZ", dxcc = 318, state = "34")))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.WAJA, qso("JH0ABC", dxcc = 339, state = "GD")))
|
||||
// WAS: only US states count
|
||||
assertEquals("CA", AwardCalculator.awardKey(AwardType.WAS, qso("W1AW", dxcc = 291, state = "CA")))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.WAS, qso("BG7XYZ", dxcc = 318, state = "CA")))
|
||||
// out-of-range CQ zone and VUCC have no key
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.WAZ, qso("BG7XYZ", cqz = 41)))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.VUCC, cn))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `region qsos dedupe multi-grid copies and sort oldest first`() {
|
||||
val older = GridQso(
|
||||
call = "BA7AAA", epochMs = 100L, satName = "SO-50", mode = "FM",
|
||||
bandUp = "2M", bandDown = "70CM", dxcc = 318
|
||||
)
|
||||
val newer = GridQso(
|
||||
call = "BA7BBB", epochMs = 200L, satName = "SO-50", mode = "FM",
|
||||
bandUp = "2M", bandDown = "70CM", dxcc = 318
|
||||
)
|
||||
val store = mapOf(
|
||||
"OL62" to listOf(newer, older),
|
||||
"PM95" to listOf(older), // same QSO recorded under a second worked grid
|
||||
"OL62,PM01" to listOf(qso("W1AW", dxcc = 291))
|
||||
)
|
||||
val result = AwardCalculator.regionQsos(store, AwardType.DXCC, "318")
|
||||
assertEquals(listOf(older, newer), result)
|
||||
assertEquals(emptyList<GridQso>(), AwardCalculator.regionQsos(store, AwardType.DXCC, "292"))
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,7 @@
|
||||
<string name="map_visibility">能见度:%s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count" formatted="false">%d 个呼号 · %d 次通联</string>
|
||||
<string name="region_qso_empty">该区域暂无已确认通联</string>
|
||||
<string name="grid_qso_count_one">%d QSO</string>
|
||||
<string name="grid_qso_count_many">%d QSOs</string>
|
||||
<string name="grid_qso_first">首通</string>
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
<string name="map_visibility">Visibility: %s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count" formatted="false">%d callsigns · %d QSOs</string>
|
||||
<string name="region_qso_empty">No confirmed QSOs in this region yet</string>
|
||||
<string name="grid_qso_count_one">%d QSO</string>
|
||||
<string name="grid_qso_count_many">%d QSOs</string>
|
||||
<string name="grid_qso_first">First</string>
|
||||
|
||||
@@ -4,10 +4,22 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.rtbishop.look4sat.feature.map"
|
||||
testOptions {
|
||||
unitTests {
|
||||
// Robolectric needs real Android resources for Compose UI tests.
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.other.osmdroid)
|
||||
// Real org.json for unit tests (android.jar stub throws "not mocked")
|
||||
testImplementation(libs.test.json)
|
||||
testImplementation(libs.test.junit4)
|
||||
testImplementation(libs.test.coroutines)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.test.core)
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.debug.manifest)
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated
|
||||
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated
|
||||
+53
-6
@@ -138,6 +138,14 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
style = Paint.Style.FILL
|
||||
color = Color.argb(90, 76, 217, 100)
|
||||
}
|
||||
private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.FILL
|
||||
color = Color.argb(230, 64, 68, 76)
|
||||
}
|
||||
private val workedDotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.FILL
|
||||
color = Color.argb(230, 76, 217, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recolor the amber boundary/label paints for the active map theme (dark amber + white halo on
|
||||
@@ -172,6 +180,15 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
/** Codes of worked regions -> green fill. */
|
||||
var workedCodes: Set<String> = emptySet()
|
||||
|
||||
/** Code of the tapped region whose detail dialog is open -> thicker outline. */
|
||||
var selectedCode: String? = null
|
||||
|
||||
/** The region under a tap at the given geo point on the map at [zoom],
|
||||
* or null when the tap lands on empty space. Delegates to [AwardHitTest]
|
||||
* so the tap tolerance matches the tiny-region dots drawn in [draw]. */
|
||||
fun regionAt(latitude: Double, longitude: Double, zoom: Double): AwardRegion? =
|
||||
AwardHitTest.hitRegion(regions, regionBounds, latitude, longitude, zoom)
|
||||
|
||||
/** Bounding boxes (computed on region assignment) for cheap culling. */
|
||||
private val regionBounds = mutableListOf<DoubleArray>() // [minLon,minLat,maxLon,maxLat]
|
||||
|
||||
@@ -211,6 +228,14 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
if (b[1] > topLat || b[3] < bottomLat) continue
|
||||
if (b[0] > rightLon || b[2] < leftLon) continue
|
||||
val worked = region.code in workedCodes
|
||||
val selected = region.code == selectedCode
|
||||
|
||||
// Projected bbox size: decides between the region's own shape and
|
||||
// the minimum-size dot below, and gates the label.
|
||||
val regionW = abs((b[2] - b[0]) / 360.0 * worldWidthPx).toFloat()
|
||||
val regionH =
|
||||
abs((projectionToY(projection, b[3]) ?: 0f) - (projectionToY(projection, b[1]) ?: 0f))
|
||||
val tiny = max(regionW, regionH) <= AwardHitTest.MIN_SHAPE_PX
|
||||
|
||||
val path = Path()
|
||||
var pathHasPoints = false
|
||||
@@ -237,10 +262,30 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
pathHasPoints = traceSegment(path, segment, projection, centerLon, worldWidthPx, closeRing) || pathHasPoints
|
||||
}
|
||||
}
|
||||
if (!pathHasPoints) continue
|
||||
if (!pathHasPoints && !tiny) continue
|
||||
|
||||
if (worked) canvas.drawPath(path, workedPaint)
|
||||
linePaint.strokeWidth = if (selected) LINE_WIDTH_SELECTED else LINE_WIDTH
|
||||
canvas.drawPath(path, linePaint)
|
||||
linePaint.strokeWidth = LINE_WIDTH
|
||||
|
||||
// Tiny-region fallback (Macao, island DXCCs at low zoom): the own
|
||||
// shape is sub-pixel and invisible, so draw a minimum-size dot at
|
||||
// the bbox centre — green when worked — and let it carry the
|
||||
// region. AwardHitTest gives small regions (dot-sized included) an
|
||||
// inflated MIN_TAP_TARGET_PX square hit area at the same centre,
|
||||
// so the dot is reliably tappable at any zoom (Tianditu tiles have
|
||||
// no zoom cap, so "just zoom in" is not a workable answer for
|
||||
// finding these).
|
||||
if (tiny) {
|
||||
val cx = projectionToX(projection, normalizeLon((b[0] + b[2]) / 2.0), centerLon, worldWidthPx)
|
||||
val cy = projectionToY(projection, (b[1] + b[3]) / 2.0)
|
||||
if (cx != null && cy != null) {
|
||||
val r = if (selected) DOT_RADIUS_PX + 3f else DOT_RADIUS_PX
|
||||
canvas.drawCircle(cx, cy, r, if (worked) workedDotPaint else dotPaint)
|
||||
canvas.drawCircle(cx, cy, r, linePaint)
|
||||
}
|
||||
}
|
||||
|
||||
// Label: draw when its anchor is on screen and the region is big
|
||||
// enough on screen to hold the text (avoid clutter at low zoom).
|
||||
@@ -250,11 +295,10 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
val lx = projectionToX(projection, labelLonNorm, centerLon, worldWidthPx) ?: continue
|
||||
val ly = projectionToY(projection, region.labelLat) ?: continue
|
||||
// Screen-size gate: the label only when the region spans enough px.
|
||||
// force_label regions (HK / Macau) always draw — their tiny land
|
||||
// bbox would otherwise stay below the gate forever, and their label
|
||||
// anchor sits out on the sea where there is room for the text.
|
||||
val regionH = (projectionToY(projection, b[3]) ?: 0f) - (projectionToY(projection, b[1]) ?: 0f)
|
||||
if (abs(regionH) < MIN_LABEL_REGION_PX && !region.forceLabel) continue
|
||||
// force_label regions (HK / Macau) and tiny regions always draw —
|
||||
// their bbox would otherwise stay below the gate forever, and they
|
||||
// are exactly the ones that would otherwise be unfindable.
|
||||
if (abs(regionH) < MIN_LABEL_REGION_PX && !region.forceLabel && !tiny) continue
|
||||
val label = if (useEnglishLabels) region.nameEn ?: region.name else region.name
|
||||
canvas.drawText(label, lx, ly - textHalfHeight, labelPaint)
|
||||
}
|
||||
@@ -424,5 +468,8 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
private companion object {
|
||||
const val MIN_LABEL_REGION_PX = 42f
|
||||
const val MAX_MERCATOR_LAT = 85.05113 // Web Mercator latitude limit
|
||||
const val LINE_WIDTH = 1.5f
|
||||
const val LINE_WIDTH_SELECTED = 3f
|
||||
const val DOT_RADIUS_PX = 6f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.map
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.max
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Tap hit-testing for the award boundary overlay: which region sits under the
|
||||
* user's finger in DXCC/WAPC/WAJA/WAZ/WAS view. Pure geometry (no osmdroid
|
||||
* types) so it is unit-testable; the overlay delegates to [hitRegion].
|
||||
*
|
||||
* Three hit shapes, mirroring how the overlay draws:
|
||||
* - a small region (projects to <= [MIN_TAP_TARGET_PX] at the current zoom —
|
||||
* including the dot-rendered ones) is hit by an inflated square target of
|
||||
* [MIN_TAP_TARGET_PX] around its bbox centre, so finger taps land even on
|
||||
* dot-sized islands (user request 2026-10-05);
|
||||
* - a normal region is hit by an even-odd point-in-polygon test on its rings.
|
||||
* Small targets win over polygon hits (Macao sits inside Guangdong's outline),
|
||||
* and among polygon hits the smallest region wins (Hong Kong over China).
|
||||
*
|
||||
* - a scattered sea archipelago (>= 2 disjoint island rings, every island
|
||||
* small — see [ARCHIPELAGO_MAX_RING_DEG]) is additionally hit anywhere in
|
||||
* its overall envelope, so tapping open sea between the islands lands on
|
||||
* the entity (user request 2026-10-06: 海上的群岛按照整体包络来判断 hit).
|
||||
* Priority: small target > land polygon > archipelago envelope — tapping the
|
||||
* Spanish coast that happens to lie in the Balearic envelope still opens
|
||||
* Spain; only sea inside the envelope belongs to the archipelago.
|
||||
*/
|
||||
object AwardHitTest {
|
||||
|
||||
/** Projected size (max of width/height, px) below which a region is
|
||||
* rendered as a minimum-size dot instead of its own shape. */
|
||||
const val MIN_SHAPE_PX = 8f
|
||||
|
||||
/** Minimum tap target (px, square) for SMALL regions: any region whose
|
||||
* projected size is below this gets an inflated square hit area of this
|
||||
* size around its bbox centre (user request 2026-10-05 — small DXCCs
|
||||
* like Hong Kong are hard to hit with a finger at their own size). */
|
||||
const val MIN_TAP_TARGET_PX = 80f
|
||||
|
||||
/** Largest per-island span (deg) for a region to count as a scattered sea
|
||||
* archipelago: every ring must be a small island, never a mainland
|
||||
* (Japan/UK/Indonesia stay polygon-only). Archipelago islands are
|
||||
* finger-proof at low zoom, so their hit region is the overall envelope. */
|
||||
const val ARCHIPELAGO_MAX_RING_DEG = 2.5
|
||||
|
||||
/**
|
||||
* The region under a tap at [tapLat]/[tapLon] on the map at [zoom], or
|
||||
* null when the tap lands on empty space. [bounds] holds each region's
|
||||
* `[minLon, minLat, maxLon, maxLat]` in [regions] order
|
||||
* (AwardBoundaryOverlay.regionBounds).
|
||||
*/
|
||||
fun hitRegion(
|
||||
regions: List<AwardRegion>,
|
||||
bounds: List<DoubleArray>,
|
||||
tapLat: Double,
|
||||
tapLon: Double,
|
||||
zoom: Double
|
||||
): AwardRegion? {
|
||||
val worldWidthPx = 256.0 * 2.0.pow(zoom)
|
||||
val lonDegPerPx = 360.0 / worldWidthPx
|
||||
val latDegPerPx = (lonDegPerPx * cos(Math.toRadians(tapLat))).coerceAtLeast(1e-9)
|
||||
var bestSmall: AwardRegion? = null
|
||||
var bestSmallDist = Double.MAX_VALUE
|
||||
var bestPoly: AwardRegion? = null
|
||||
var bestPolyArea = Double.MAX_VALUE
|
||||
var bestArch: AwardRegion? = null
|
||||
var bestArchArea = Double.MAX_VALUE
|
||||
for (i in regions.indices) {
|
||||
val b = bounds.getOrNull(i) ?: continue
|
||||
// Cheap bbox prefilter with the tap-target slack (longitudes wrap).
|
||||
val slack = MIN_TAP_TARGET_PX / 2.0
|
||||
if (tapLat < b[1] - slack * latDegPerPx || tapLat > b[3] + slack * latDegPerPx) continue
|
||||
val cLon = (b[0] + b[2]) / 2.0
|
||||
val dLon = wrap180(tapLon - cLon)
|
||||
if (abs(dLon) > (b[2] - b[0]) / 2.0 + slack * lonDegPerPx) continue
|
||||
val w = (b[2] - b[0]) / lonDegPerPx
|
||||
val h = (b[3] - b[1]) / latDegPerPx
|
||||
val cLat = (b[1] + b[3]) / 2.0
|
||||
val dx = dLon / lonDegPerPx
|
||||
val dy = (tapLat - cLat) / latDegPerPx
|
||||
val dist = hypot(dx, dy)
|
||||
val region = regions[i]
|
||||
if (max(w, h) <= MIN_TAP_TARGET_PX) {
|
||||
// Small region: hit by an inflated square target around the
|
||||
// bbox centre — at least MIN_TAP_TARGET_PX on each side — so
|
||||
// finger taps land even on dot-sized entities.
|
||||
if (abs(dx) <= slack && abs(dy) <= slack && dist < bestSmallDist) {
|
||||
bestSmall = region
|
||||
bestSmallDist = dist
|
||||
}
|
||||
} else if (contains(region, tapLat, tapLon)) {
|
||||
// Land polygon hit: the smallest enclosing region wins.
|
||||
if (w * h < bestPolyArea) {
|
||||
bestPoly = region
|
||||
bestPolyArea = w * h
|
||||
}
|
||||
} else if (isArchipelago(region)) {
|
||||
// Scattered sea archipelago: the overall envelope is the hit
|
||||
// region (open sea between the islands belongs to the entity).
|
||||
// The envelope is measured in one unwrapped longitude frame so
|
||||
// antimeridian groups (Fiji-style) keep a contiguous box.
|
||||
val env = envelope(region)
|
||||
val halfW = (env[2] - env[1]) / 2.0
|
||||
val halfH = (env[4] - env[3]) / 2.0
|
||||
val envLon = env[0] + wrap180(tapLon - env[0])
|
||||
val inEnvelope =
|
||||
abs(envLon - (env[1] + env[2]) / 2.0) <= halfW + slack * lonDegPerPx &&
|
||||
abs(tapLat - (env[3] + env[4]) / 2.0) <= halfH + slack * latDegPerPx
|
||||
val area = 4.0 * halfW * halfH
|
||||
if (inEnvelope && area < bestArchArea) {
|
||||
bestArch = region
|
||||
bestArchArea = area
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestSmall ?: bestPoly ?: bestArch
|
||||
}
|
||||
|
||||
/**
|
||||
* Scattered sea archipelago: at least two disjoint island rings and every
|
||||
* ring small (<= [ARCHIPELAGO_MAX_RING_DEG] in both lon and lat, measured
|
||||
* in an unwrapped longitude frame) — no dominant mainland. Mainland
|
||||
* countries with stray islands (Japan, UK, Indonesia) fail the size test
|
||||
* and keep plain polygon hits.
|
||||
*/
|
||||
fun isArchipelago(region: AwardRegion): Boolean {
|
||||
if (region.rings.size < 2) return false
|
||||
for (ring in region.rings) {
|
||||
if (ring.isEmpty()) continue
|
||||
val anchor = ring[0][0]
|
||||
var minX = 0.0
|
||||
var maxX = 0.0
|
||||
var minY = ring[0][1]
|
||||
var maxY = ring[0][1]
|
||||
for (p in ring) {
|
||||
val x = wrap180(p[0] - anchor)
|
||||
if (x < minX) minX = x
|
||||
if (x > maxX) maxX = x
|
||||
if (p[1] < minY) minY = p[1]
|
||||
if (p[1] > maxY) maxY = p[1]
|
||||
}
|
||||
if (max(maxX - minX, maxY - minY) > ARCHIPELAGO_MAX_RING_DEG) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Envelope of all rings in one longitude frame anchored at the first ring's
|
||||
* first point: `[anchorLon, minX, maxX, minY, maxY]`. Longitudes past ±180
|
||||
* are unwrapped into the anchor's frame, keeping antimeridian groups whole.
|
||||
*/
|
||||
private fun envelope(region: AwardRegion): DoubleArray {
|
||||
val first = region.rings.firstOrNull { it.isNotEmpty() } ?: return doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
val anchor = first[0][0]
|
||||
var minX = 0.0
|
||||
var maxX = 0.0
|
||||
var minY = first[0][1]
|
||||
var maxY = first[0][1]
|
||||
for (ring in region.rings) {
|
||||
for (p in ring) {
|
||||
val x = wrap180(p[0] - anchor)
|
||||
if (x < minX) minX = x
|
||||
if (x > maxX) maxX = x
|
||||
if (p[1] < minY) minY = p[1]
|
||||
if (p[1] > maxY) maxY = p[1]
|
||||
}
|
||||
}
|
||||
return doubleArrayOf(anchor, anchor + minX, anchor + maxX, minY, maxY)
|
||||
}
|
||||
|
||||
/** Even-odd point-in-polygon across all rings (rings are disjoint parts). */
|
||||
fun contains(region: AwardRegion, lat: Double, lon: Double): Boolean =
|
||||
region.rings.any { ringContains(it, lat, lon) }
|
||||
|
||||
/**
|
||||
* Point-in-ring with the ring unwrapped into one continuous longitude
|
||||
* frame (same trick as the overlay's tracer). Rings that cross the
|
||||
* antimeridian (Fiji, the Pacific CQ zones) are stored with raw lons past
|
||||
* ±180 or folded; testing them in raw coordinates would split the polygon
|
||||
* at the date line and miss taps inside it. The tap is shifted to the
|
||||
* frame's nearest world copy first.
|
||||
*/
|
||||
private fun ringContains(ring: List<DoubleArray>, lat: Double, lon: Double): Boolean {
|
||||
val n = ring.size
|
||||
if (n < 3) return false
|
||||
val anchor = ring[0][0]
|
||||
val lons = DoubleArray(n)
|
||||
var prevDelta = 0.0
|
||||
for (i in 0 until n) {
|
||||
var delta = ring[i][0] - anchor
|
||||
if (i > 0) {
|
||||
while (delta - prevDelta > 180.0) delta -= 360.0
|
||||
while (prevDelta - delta > 180.0) delta += 360.0
|
||||
}
|
||||
lons[i] = anchor + delta
|
||||
prevDelta = delta
|
||||
}
|
||||
val tapLon = anchor + wrap180(lon - anchor)
|
||||
var inside = false
|
||||
for (j in 0 until n) {
|
||||
val k = (j + 1) % n
|
||||
val y1 = ring[j][1]
|
||||
val y2 = ring[k][1]
|
||||
if ((y1 > lat) != (y2 > lat)) {
|
||||
val x = lons[j] + (lat - y1) / (y2 - y1) * (lons[k] - lons[j])
|
||||
if (x > tapLon) inside = !inside
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
/** Shortest signed longitude difference a - b, in (-180, 180]. */
|
||||
private fun wrap180(deg: Double): Double {
|
||||
var d = deg % 360.0
|
||||
if (d <= -180.0) d += 360.0
|
||||
if (d > 180.0) d -= 360.0
|
||||
return d
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@@ -304,6 +305,10 @@ private fun MapScreen(
|
||||
// Tapped worked grid -> centered QSO dialog. Local UI state: the map is the
|
||||
// only consumer and it resets when leaving the page.
|
||||
var selectedGrid by remember { mutableStateOf<String?>(null) }
|
||||
// Tapped award region (DXCC/WAPC/WAJA/WAZ/WAS) -> its QSO detail dialog,
|
||||
// paired with the award type it was tapped under. Local UI state like the
|
||||
// grid selection above: the map is the only consumer.
|
||||
var selectedRegion by remember { mutableStateOf<Pair<AwardType, AwardRegion>?>(null) }
|
||||
// Selected award filter. Lives in an Activity-scoped ViewModel so it
|
||||
// survives page switches; defaults to VUCC only once per process (cold start).
|
||||
var selectedAward by mapFilterViewModel.selectedAward
|
||||
@@ -375,6 +380,20 @@ private fun MapScreen(
|
||||
val receiver = object : org.osmdroid.events.MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
|
||||
if (!isGridMode || p == null) return false
|
||||
// Award views (DXCC/WAPC/WAJA/WAZ/WAS): the tap resolves to the
|
||||
// boundary region under the finger and opens its QSO detail
|
||||
// dialog. No zoom gate here — unlike the grid path below — so
|
||||
// tiny islands stay reachable while zoomed out (Tianditu tiles
|
||||
// have no zoom cap, so "just zoom in" is not an answer).
|
||||
val awardMode = selectedAward?.takeIf { it != AwardType.VUCC }
|
||||
if (awardMode != null) {
|
||||
val overlay = mapView.overlays.getOrNull(OVERLAY_GRID) as? AwardBoundaryOverlay
|
||||
?: return false
|
||||
val region = overlay.regionAt(p.latitude, p.longitude, mapView.zoomLevelDouble)
|
||||
?: return false
|
||||
selectedRegion = awardMode to region
|
||||
return true
|
||||
}
|
||||
val zoom = mapView.zoomLevelDouble
|
||||
// Allow tapping worked cells as soon as the 4-char grid LINES
|
||||
// appear (GRID_ZOOM_SUB); grid labels now appear at that zoom too.
|
||||
@@ -397,6 +416,12 @@ private fun MapScreen(
|
||||
(mapView.overlays.getOrNull(OVERLAY_GRID) as? MaidenheadGridOverlay)?.selectedGrid = selectedGrid
|
||||
mapView.invalidate()
|
||||
}
|
||||
// Same for the award boundary overlay's selected region.
|
||||
LaunchedEffect(selectedRegion) {
|
||||
(mapView.overlays.getOrNull(OVERLAY_GRID) as? AwardBoundaryOverlay)?.selectedCode =
|
||||
selectedRegion?.second?.code
|
||||
mapView.invalidate()
|
||||
}
|
||||
LaunchedEffect(uiState.track) {
|
||||
// In grid mode the map is centered on the local grid square; following
|
||||
// the satellite subpoint here would override that centering on every
|
||||
@@ -564,6 +589,22 @@ private fun MapScreen(
|
||||
onPinMark = { call -> onAction(MapAction.PinMarkedStation(grid, call)) }
|
||||
)
|
||||
}
|
||||
// Region detail dialog for a tapped award region. Read-only: the Match
|
||||
// button and station marks are grid-scoped and stay hidden here. The list
|
||||
// is the GLOBAL one (all 台址) — the non-VUCC awards count and fill the
|
||||
// map globally, so their detail lists use the same scope.
|
||||
selectedRegion?.let { (type, region) ->
|
||||
val regionQsos = remember(uiState.workedGridQsos, type, region) {
|
||||
AwardCalculator.regionQsos(uiState.workedGridQsos, type, region.code)
|
||||
}
|
||||
RegionQsoDialog(
|
||||
type = type,
|
||||
region = region,
|
||||
qsos = regionQsos,
|
||||
isUtc = uiState.isUtc,
|
||||
onDismiss = { selectedRegion = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// region Worked-grid QSO dialog
|
||||
@@ -720,6 +761,110 @@ private fun WorkedGridQsoDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered dialog listing the confirmed satellite QSOs counted for a tapped
|
||||
* award region (DXCC/WAPC/WAJA/WAZ/WAS). Read-only variant of the worked-grid
|
||||
* dialog: the header shows the region name + code, and the same expandable
|
||||
* callsign rows. Match / station marks are grid-scoped and stay hidden.
|
||||
* Dismissed by tapping outside. An unworked region opens with an empty list.
|
||||
*/
|
||||
@Composable
|
||||
private fun RegionQsoDialog(
|
||||
type: AwardType,
|
||||
region: AwardRegion,
|
||||
qsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>,
|
||||
isUtc: Boolean,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
// WAZ regions are named by their bare zone number in the asset, so the
|
||||
// title gets the full "CQ ZONE 24" wording (user request 2026-10-05) and
|
||||
// the code is not repeated in the subtitle. DXCC carries its award prefix
|
||||
// in the subtitle ("DXCC 318", same request); other awards keep the bare
|
||||
// code.
|
||||
val isZone = type == AwardType.WAZ
|
||||
val codeLabel = when {
|
||||
isZone -> ""
|
||||
type == AwardType.DXCC -> "DXCC ${region.code} · "
|
||||
else -> "${region.code} · "
|
||||
}
|
||||
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
modifier = Modifier.fillMaxWidth(0.88f)
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
|
||||
// Match the grid dialog header height exactly: its Match button
|
||||
// enforces a 48dp minimum touch target, inflating the row and
|
||||
// lowering the title to 6dp from the dialog edge.
|
||||
.defaultMinSize(minHeight = 48.dp)
|
||||
) {
|
||||
// Header mirrors the worked-grid dialog exactly (same font,
|
||||
// same paddings, same weight layout) — user request
|
||||
// 2026-10-05: 标题字体/位置与 VUCC 的统一.
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (isZone) "CQ ZONE ${region.code}" else regionDisplayName(region),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
Text(
|
||||
text = codeLabel + stringResource(
|
||||
R.string.grid_qso_calls_count,
|
||||
qsos.map { it.call }.distinct().size,
|
||||
qsos.size
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
androidx.compose.material3.HorizontalDivider()
|
||||
Column(
|
||||
Modifier
|
||||
.heightIn(max = 380.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
if (qsos.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.region_qso_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
)
|
||||
} else {
|
||||
// Same grouping as the worked-grid dialog: by callsign
|
||||
// preserving first-contact order (list is oldest-first).
|
||||
val grouped = remember(qsos) {
|
||||
qsos.groupBy { it.call }.entries.sortedBy { it.value.first().epochMs }
|
||||
}
|
||||
grouped.forEach { (call, callQsos) ->
|
||||
WorkedGridCallRow(call, callQsos = callQsos, isUtc = isUtc)
|
||||
androidx.compose.material3.HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Region display label, same language rule as the overlay's map labels. */
|
||||
@Composable
|
||||
private fun regionDisplayName(region: AwardRegion): String {
|
||||
val useEnglish =
|
||||
androidx.compose.ui.platform.LocalConfiguration.current.locales[0].language != "zh"
|
||||
return if (useEnglish) region.nameEn ?: region.name else region.name
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkedStationRow(
|
||||
marked: com.rtbishop.look4sat.core.domain.model.MarkedStation,
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.map
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pin the award-region tap rules: tiny regions (rendered as dots) are hit by a
|
||||
* tap radius around their bbox centre, normal regions by point-in-polygon,
|
||||
* tiny dots win over enclosing polygons (Macao inside Guangdong), and rings
|
||||
* crossing the antimeridian stay tappable on both sides of the date line.
|
||||
*/
|
||||
class AwardHitTestTest {
|
||||
|
||||
/** Axis-aligned box region: [lon1, lon2] x [lat1, lat2] as one ring. */
|
||||
private fun box(code: String, lon1: Double, lat1: Double, lon2: Double, lat2: Double) = AwardRegion(
|
||||
code = code,
|
||||
name = code,
|
||||
labelLon = (lon1 + lon2) / 2.0,
|
||||
labelLat = (lat1 + lat2) / 2.0,
|
||||
rings = listOf(
|
||||
listOf(
|
||||
doubleArrayOf(lon1, lat1), doubleArrayOf(lon2, lat1),
|
||||
doubleArrayOf(lon2, lat2), doubleArrayOf(lon1, lat2)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private fun boundsOf(regions: List<AwardRegion>): List<DoubleArray> = regions.map { r ->
|
||||
val lons = r.rings.flatten().map { it[0] }
|
||||
val lats = r.rings.flatten().map { it[1] }
|
||||
doubleArrayOf(lons.min(), lats.min(), lons.max(), lats.max())
|
||||
}
|
||||
|
||||
private fun hit(regions: List<AwardRegion>, lat: Double, lon: Double, zoom: Double): AwardRegion? =
|
||||
AwardHitTest.hitRegion(regions, boundsOf(regions), lat, lon, zoom)
|
||||
|
||||
@Test
|
||||
fun `tiny region is hit inside the inflated target and missed beyond it`() {
|
||||
// Macao-sized box (0.07 x 0.10 deg): 1.6 px wide at zoom 5 -> a dot,
|
||||
// but its tap target is the MIN_TAP_TARGET_PX square around the centre.
|
||||
val regions = listOf(box("MO", 113.535, 22.108, 113.604, 22.212))
|
||||
assertEquals("MO", hit(regions, 22.16, 113.57, 5.0)?.code)
|
||||
// half a degree away is still inside the 40 px half-target at zoom 5
|
||||
assertEquals("MO", hit(regions, 22.16, 114.07, 5.0)?.code)
|
||||
// two degrees away (~45 px) is outside it
|
||||
assertNull(hit(regions, 22.16, 115.57, 5.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `small region gets an inflated tap target beyond its own size`() {
|
||||
// Hong Kong-sized box: 13 x 9 px at zoom 5 — too small for a finger
|
||||
// at its own size, so the tap target inflates to 80 px square
|
||||
// (user request 2026-10-05).
|
||||
val regions = listOf(box("HK", 113.843, 22.178, 114.406, 22.559))
|
||||
assertEquals("HK", hit(regions, 22.37, 114.77, 5.0)?.code) // ~0.6 deg off = 14 px
|
||||
assertEquals("HK", hit(regions, 22.37, 115.37, 5.0)?.code) // ~1.2 deg off = 27 px
|
||||
assertNull(hit(regions, 22.37, 116.37, 5.0)) // ~2.2 deg off = 50 px
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normal region is hit by point in polygon only`() {
|
||||
val regions = listOf(box("GD", 109.5, 20.2, 117.5, 25.6))
|
||||
assertEquals("GD", hit(regions, 23.1, 113.3, 5.0)?.code)
|
||||
assertNull(hit(regions, 30.0, 113.3, 5.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tiny region wins over the polygon that encloses it`() {
|
||||
val regions = listOf(
|
||||
box("GD", 109.5, 20.2, 117.5, 25.6),
|
||||
box("MO", 113.535, 22.108, 113.604, 22.212)
|
||||
)
|
||||
assertEquals("MO", hit(regions, 22.16, 113.57, 5.0)?.code)
|
||||
// away from the dot the enclosing province answers for its own area
|
||||
assertEquals("GD", hit(regions, 24.5, 110.0, 5.0)?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `smallest enclosing polygon wins`() {
|
||||
val regions = listOf(
|
||||
box("CN", 73.0, 18.0, 135.0, 53.6),
|
||||
box("HK", 113.8, 22.1, 114.5, 22.6)
|
||||
)
|
||||
assertEquals("HK", hit(regions, 22.3, 114.1, 5.0)?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ring crossing the antimeridian is tappable on both sides`() {
|
||||
// Fiji-style ring whose raw lons run past +180 (wide enough to stay in
|
||||
// the polygon-branch of the hit test).
|
||||
val region = AwardRegion(
|
||||
code = "176",
|
||||
name = "Fiji",
|
||||
labelLon = 180.0,
|
||||
labelLat = -18.0,
|
||||
rings = listOf(
|
||||
listOf(
|
||||
doubleArrayOf(172.0, -22.0), doubleArrayOf(188.0, -22.0),
|
||||
doubleArrayOf(188.0, -14.0), doubleArrayOf(172.0, -14.0)
|
||||
)
|
||||
)
|
||||
)
|
||||
val regions = listOf(region)
|
||||
assertEquals("176", hit(regions, -18.0, 175.0, 4.0)?.code)
|
||||
assertEquals("176", hit(regions, -18.0, -175.0, 4.0)?.code)
|
||||
assertNull(hit(regions, -18.0, 165.0, 4.0))
|
||||
}
|
||||
|
||||
/** Two 0.2 x 0.2 deg island boxes [lon] apart, as one archipelago entity. */
|
||||
private fun archipelago(code: String, lon1: Double, lon2: Double, lat: Double = -10.0) =
|
||||
AwardRegion(
|
||||
code = code,
|
||||
name = code,
|
||||
labelLon = (lon1 + lon2) / 2.0,
|
||||
labelLat = lat,
|
||||
rings = listOf(
|
||||
listOf(
|
||||
doubleArrayOf(lon1 - 0.1, lat - 0.1), doubleArrayOf(lon1 + 0.1, lat - 0.1),
|
||||
doubleArrayOf(lon1 + 0.1, lat + 0.1), doubleArrayOf(lon1 - 0.1, lat + 0.1)
|
||||
),
|
||||
listOf(
|
||||
doubleArrayOf(lon2 - 0.1, lat - 0.1), doubleArrayOf(lon2 + 0.1, lat - 0.1),
|
||||
doubleArrayOf(lon2 + 0.1, lat + 0.1), doubleArrayOf(lon2 - 0.1, lat + 0.1)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `sea archipelago is hit by its overall envelope between the islands`() {
|
||||
// Two tiny islands 10 deg apart: at zoom 5 the bbox is ~230 px, so the
|
||||
// per-region inflated target does not apply — the whole envelope does
|
||||
// (user request 2026-10-06: 海上的群岛按照整体包络来判断 hit).
|
||||
val regions = listOf(archipelago("191", -165.0, -155.0))
|
||||
assertEquals("191", hit(regions, -10.0, -160.0, 5.0)?.code) // open sea in the middle
|
||||
assertEquals("191", hit(regions, -10.0, -156.0, 5.0)?.code) // near the east island
|
||||
assertNull(hit(regions, -10.0, -152.0, 5.0)) // ~2.8 deg past the envelope
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `small entity inside the archipelago envelope keeps its own target`() {
|
||||
val regions = listOf(
|
||||
archipelago("191", -165.0, -155.0),
|
||||
box("KS", -161.1, -9.6, -160.9, -9.4) // tiny standalone island in the gap
|
||||
)
|
||||
// Near KS the small target (80 px) wins over the archipelago envelope.
|
||||
assertEquals("KS", hit(regions, -9.5, -160.4, 5.0)?.code)
|
||||
// Away from it the open sea belongs to the archipelago.
|
||||
assertEquals("191", hit(regions, -10.0, -156.0, 5.0)?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mainland country with a stray island is not envelope hit`() {
|
||||
// Big mainland ring + one small island ring: sea inside the bbox is NOT
|
||||
// a tap target (the group is not a scattered archipelago).
|
||||
val regions = listOf(
|
||||
AwardRegion(
|
||||
code = "JP",
|
||||
name = "JP",
|
||||
labelLon = 133.0,
|
||||
labelLat = 37.0,
|
||||
rings = listOf(
|
||||
listOf(
|
||||
doubleArrayOf(130.0, 31.0), doubleArrayOf(135.0, 31.0),
|
||||
doubleArrayOf(135.0, 42.0), doubleArrayOf(130.0, 42.0)
|
||||
),
|
||||
listOf(
|
||||
doubleArrayOf(138.0, 34.2), doubleArrayOf(138.3, 34.2),
|
||||
doubleArrayOf(138.3, 34.5), doubleArrayOf(138.0, 34.5)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
assertNull(hit(regions, 35.0, 137.0, 5.0)) // sea inside the bbox
|
||||
assertEquals("JP", hit(regions, 37.0, 132.0, 5.0)?.code) // on the mainland
|
||||
assertEquals("JP", hit(regions, 34.35, 138.15, 5.0)?.code) // on the island itself
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `archipelago envelope crossing the antimeridian stays contiguous`() {
|
||||
// Two islands straddling 180: the raw bbox is 357 deg wide, but the
|
||||
// unwrapped envelope is a tight 3 deg box around the date line.
|
||||
val regions = listOf(archipelago("FJ", 178.65, -178.65, lat = -18.0))
|
||||
assertEquals("FJ", hit(regions, -18.0, 180.0, 5.0)?.code) // sea gap at the date line
|
||||
assertEquals("FJ", hit(regions, -18.0, -180.0, 5.0)?.code) // same, other sign
|
||||
assertNull(hit(regions, -18.0, 176.0, 5.0)) // well west of the group
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
|
||||
package com.rtbishop.look4sat.feature.map
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Regression test for dialog header spacing parity. The worked-grid (VUCC) dialog header
|
||||
* carries a trailing Match button whose 48dp minimum touch target inflates the header row,
|
||||
* pushing its title down to 6dp from the dialog edge. Region dialogs (DXCC/WAPC/WAJA/WAS/
|
||||
* WAZ) without the button used to hug the top edge at 4dp, reading as cramped. The region
|
||||
* header now pins the same 48dp row height, so BOTH titles must land at the identical
|
||||
* offset below their header's top edge. Measured via real Compose layout (not guessed),
|
||||
* see look4sat-customization/references/robolectric-compose-ui-testing-wsl-2026-09.md.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34])
|
||||
class DialogHeaderSpacingTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun gridAndRegionHeadersPlaceTitlesAtTheSameOffset() {
|
||||
composeRule.setContent {
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Fixed anchors make the expected offsets unambiguous:
|
||||
// each title must sit exactly TITLE_OFFSET below its header.
|
||||
Box(modifier = Modifier.offset(y = GRID_Y)) {
|
||||
GridHeader()
|
||||
}
|
||||
Box(modifier = Modifier.offset(y = REGION_Y)) {
|
||||
RegionHeader()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithTag(GRID_TITLE_TAG)
|
||||
.assertTopPositionInRootIsEqualTo(GRID_Y + TITLE_OFFSET)
|
||||
composeRule.onNodeWithTag(REGION_TITLE_TAG)
|
||||
.assertTopPositionInRootIsEqualTo(REGION_Y + TITLE_OFFSET)
|
||||
}
|
||||
|
||||
/** Grid-dialog header replica: title + counts column plus the Match button. */
|
||||
@Composable
|
||||
private fun GridHeader() {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "OL62",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.testTag(GRID_TITLE_TAG)
|
||||
)
|
||||
Text(text = "3 callsigns · 5 QSOs", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
TextButton(onClick = {}) {
|
||||
Text(text = "Match", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
/** Region-dialog header replica: identical, without the trailing button. */
|
||||
@Composable
|
||||
private fun RegionHeader() {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
|
||||
.defaultMinSize(minHeight = 48.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "CQ ZONE 24",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.testTag(REGION_TITLE_TAG)
|
||||
)
|
||||
Text(text = "3 callsigns · 5 QSOs", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val GRID_TITLE_TAG = "gridDialogTitle"
|
||||
const val REGION_TITLE_TAG = "regionDialogTitle"
|
||||
val GRID_Y = 100.dp
|
||||
val REGION_Y = 300.dp
|
||||
/** Title offset from the header's top edge — must be identical for both dialogs. */
|
||||
val TITLE_OFFSET = 6.dp
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,46 @@ class DxccAssetTest {
|
||||
required.forEach { code -> assertTrue("missing code $code", code in codes) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `island and microstate batch is present`() {
|
||||
// 2026-10 additions (Natural Earth 1:10m admin-0 map units): common
|
||||
// island / microstate DXCC entities that 1:110m never carried, so a
|
||||
// worked entity like Singapore (381) could not light up at all.
|
||||
val regions = loadRegions()
|
||||
val codes = regions.map { it.getInt("code") }
|
||||
val batch = listOf(
|
||||
381, 304, // Singapore / Bahrain
|
||||
203, 260, 278, 251, 295, 233, // European microstates + Gibraltar
|
||||
257, 21, 29, 149, 256, 222, 114, 122, 106, // Malta / Balearic / Canary / Azores / Madeira / Faroe / Man / Jersey / Guernsey
|
||||
62, 64, 65, 285, 69, 89, 91, 517, 79, 84, // Caribbean
|
||||
94, 95, 97, 77, 98, 249, 96,
|
||||
103, 166, 9, 190, 160, 175, 191, 234 // Pacific
|
||||
)
|
||||
batch.forEach { code -> assertTrue("missing code $code", code in codes) }
|
||||
assertEquals("Singapore", regions.byCode(381).getString("name"))
|
||||
assertEquals("Canary Is.", regions.byCode(29).getString("name"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `B-plan island batch is present`() {
|
||||
// 2026-10 B plan: every remaining DXCC entity that has a REAL Natural
|
||||
// Earth 1:10m polygon (own feature or a ring window split out of a
|
||||
// parent: Rodrigues/Agalega of Mauritius, Ducie of Pitcairn, South
|
||||
// Georgia/Sandwich, the St. Helena family). Atoll/reef entities
|
||||
// without land polygons stay missing by design.
|
||||
val regions = loadRegions()
|
||||
val codes = regions.map { it.getInt("code") }
|
||||
val batch = listOf(
|
||||
4, 5, 12, 20, 22, 24, 35, 36, 38, 105, 111, 118, 123, 157, 159,
|
||||
165, 168, 169, 172, 173, 174, 182, 188, 189, 197, 205, 207,
|
||||
213, 219, 235, 240, 247, 250, 259, 270, 274, 277, 282, 297,
|
||||
298, 379, 409, 411, 453, 506, 513, 516, 518, 522
|
||||
)
|
||||
batch.forEach { code -> assertTrue("missing code $code", code in codes) }
|
||||
assertEquals("Wake I.", regions.byCode(297).getString("name"))
|
||||
assertEquals("Rodrigues I.", regions.byCode(207).getString("name"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no region uses an ADIF-deleted entity code`() {
|
||||
// ADIF 3.1.7 deleted DXCC codes. A polygon keyed to a deleted code
|
||||
|
||||
@@ -132,5 +132,12 @@ sealed interface RadarAction {
|
||||
|
||||
// Calculator actions
|
||||
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
|
||||
|
||||
/**
|
||||
* The transponder currently driving the calculator (null when the satellite has
|
||||
* no linear transponder). The per-satellite offset follows it instead of the
|
||||
* transceiver selection, so it loads and saves even when nothing is selected.
|
||||
*/
|
||||
data class CalculatorTransponderChanged(val catnum: Int?) : RadarAction
|
||||
data class UpdateCalculatorFrequency(val txHz: Long?, val rxHz: Long?) : RadarAction
|
||||
}
|
||||
@@ -69,6 +69,7 @@ class RadarViewModel(
|
||||
private val stationPos = settingsRepo.stationPosition.value
|
||||
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
||||
private var transponders: List<SatRadio> = emptyList()
|
||||
private var calculatorCatnum: Int? = null
|
||||
private var sstvDecoder: SstvDecoder? = null
|
||||
private var sstvRecordingJob: Job? = null
|
||||
private var cwDecoder: CwDecoder? = null
|
||||
@@ -158,12 +159,8 @@ class RadarViewModel(
|
||||
transponders = allRadios.filter { it.downlinkLow != null }
|
||||
if (allRadios.isNotEmpty()) {
|
||||
val firstUuid = allRadios.first().uuid
|
||||
val offsetKHz = allRadios.first().catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
transceivers = it.transceivers.copy(selectedUuid = firstUuid),
|
||||
calculatorOffsetKHz = offsetKHz
|
||||
)
|
||||
it.copy(transceivers = it.transceivers.copy(selectedUuid = firstUuid))
|
||||
}
|
||||
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
||||
}
|
||||
@@ -241,15 +238,8 @@ class RadarViewModel(
|
||||
// Compute toggle state before the update so we don't read post-update value
|
||||
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
||||
val newUuid = if (isTogglingOff) null else action.uuid
|
||||
val offsetKHz = if (!isTogglingOff) {
|
||||
transponders.find { it.uuid == action.uuid }
|
||||
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||
} else ""
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
transceivers = it.transceivers.copy(selectedUuid = newUuid),
|
||||
calculatorOffsetKHz = offsetKHz
|
||||
)
|
||||
it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid))
|
||||
}
|
||||
// Only update the tracking service when selecting a different transponder to
|
||||
// avoid resetting a user-adjusted TX base on re-expand
|
||||
@@ -319,7 +309,9 @@ class RadarViewModel(
|
||||
_uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) }
|
||||
}
|
||||
is RadarAction.ChangeCalculatorOffset -> {
|
||||
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
|
||||
// Keyed by the transponder driving the calculator, so the offset saves
|
||||
// even when no transceiver is selected (or selection was toggled off)
|
||||
val catnum = calculatorCatnum ?: _uiState.value.transceivers.selectedUuid?.let { uuid ->
|
||||
transponders.find { it.uuid == uuid }?.catnum
|
||||
}
|
||||
if (catnum != null) {
|
||||
@@ -327,6 +319,11 @@ class RadarViewModel(
|
||||
}
|
||||
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
|
||||
}
|
||||
is RadarAction.CalculatorTransponderChanged -> {
|
||||
calculatorCatnum = action.catnum
|
||||
val offsetKHz = action.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||
_uiState.update { it.copy(calculatorOffsetKHz = offsetKHz) }
|
||||
}
|
||||
is RadarAction.UpdateCalculatorFrequency -> {
|
||||
_uiState.update {
|
||||
if (it.calculatorTxHz == action.txHz && it.calculatorRxHz == action.rxHz) it
|
||||
|
||||
@@ -176,6 +176,13 @@ fun CalculatorPage(
|
||||
?: calculatorTransceivers.firstOrNull()
|
||||
}
|
||||
|
||||
// Keep the per-satellite offset in sync with the transponder driving the
|
||||
// calculator: load the saved value on open/satellite change, and remember
|
||||
// its catnum so editing saves even when no transceiver is selected
|
||||
LaunchedEffect(selectedTransceiver?.uuid) {
|
||||
onAction(RadarAction.CalculatorTransponderChanged(selectedTransceiver?.catnum))
|
||||
}
|
||||
|
||||
if (selectedTransceiver == null) {
|
||||
EmptyTransceiversContent(modifier)
|
||||
return
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Configure your build environment: http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
org.gradle.caching=true
|
||||
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
org.gradle.workers.max=4
|
||||
org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=768m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=false
|
||||
org.gradle.workers.max=2
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "569"
|
||||
appVersionCode = "574"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.8-ba7opf.9.4"
|
||||
appVersionName = "4.4.8-ba7opf.10.1"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user