feat(map): 奖状区域点击弹通联详情 + 过小区域圆点渲染/放大命中 + DXCC 资产补齐 88 区

- DXCC/WAPC/WAJA/WAS/WAZ 点区域弹只读通联详情(复用 VUCC 样式,隐藏匹配/标记站)
- 弹窗标题与 VUCC 统一(等宽字体、同 48dp 头部行高=同 6dp 顶距,回归测试钉死)
- WAZ 标题 CQ ZONE <n>,DXCC 副行带 DXCC <code> 前缀
- 投影过小区块渲染圆点(已通=绿)+ 标签常显;≤80px 区域点击命中放大,小实体优先
- dxcc.json 231→278 区:B 批岛屿 + 47 实体(威克/中途岛/扬马延/布韦/斯瓦尔巴/
  圣赫勒拿/毛里求斯/塞舌尔/佛得角/科摩罗/留尼汪/科索沃/斯普拉特利/黄岩岛等)
- THIRD_PARTY_NOTICES: Natural Earth 来源补 10m map units/rivers
This commit is contained in:
atsunatsu committed 2026-10-06 02:19:32 +08:00
1 parent ad970eec77
commit a24acc2bfd
13 files changed
+774 -39

No files matched your search

+6 -3
View File
@@ -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.
@@ -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)
}
@@ -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>
+12
View File
@@ -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
View File
@@ -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
@@ -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,150 @@
/*
* 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].
*
* Two 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).
*/
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
/**
* 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
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)
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 = regions[i]
bestSmallDist = dist
}
} else if (w * h < bestPolyArea && contains(regions[i], tapLat, tapLon)) {
bestPoly = regions[i]
bestPolyArea = w * h
}
}
return bestSmall ?: bestPoly
}
/** 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,126 @@
/*
* 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))
}
}
@@ -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