Compare commits

..
Author SHA1 Message Date
atsunatsu 7a7d742b54 build: release version 4.4.8-ba7opf.10.1 (574) 2026-10-06 03:19:28 +08:00
atsunatsu 146d47455f feat(map): 海上群岛按整体包络命中(点岛间海面即弹实体详情)
- 新增第三类命中目标:≥2 分离环且单岛跨度 ≤2.5° 的散布群岛,整体包络即命中区
  (±40px 触点余量),点岛链之间的海面归该实体
- 优先级:小实体 80px 目标 > 陆地多边形点内 > 群岛包络;主大陆+零星岛(日/英/印尼)
  不进群岛模式,行为不变
- 包络按单一展开经度框计算,反经线群岛(斐济式)不横扫
- 测试 6→10:包络命中、包络内小实体优先、主大陆排除、反经线包络连续
2026-10-06 03:18:57 +08:00
atsunatsu a7d99862d9 build: release version 4.4.8-ba7opf.10 (573)
gradle 内存调优:-Xmx3g/2 workers/parallel=false(WSL 8GB VM 下 lintVital OOM 修复)
2026-10-06 02:19:45 +08:00
atsunatsu a24acc2bfd 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
2026-10-06 02:19:32 +08:00
atsunatsu ad970eec77 fix(radar): 计算器 offset 改为跟随驱动计算器的转发器读写
per-satellite offset 原先只在 SelectTransmitter 里载入、并按 selectedUuid
的 catnum 保存:重开雷达页读不回(看起来"没保存"),选中被 toggle 掉后
catnum 为 null 静默丢写;且计算器页 chip 仅在 dedupe 后多于一条时渲染,
单转发器卫星(JO-97/RS-44/FO-29/AO-73/QO-100/TO-108/FO-82/QB50P1/
UKUBE-1)根本没有 chip 可点,永远触发不了载入——上游 rt-bishop 在 PR #237
评审时预言过这个加载问题。

改为 offset 跟随"驱动计算器的转发器":该转发器变化(打开/换星)即载入
已存值,按其 catnum 保存,与收发机选中状态解耦;同时删掉 loadPassData 里
的 workaround 载入与 SelectTransmitter 里的 offset 读写,单一来源、与上游
PR #265 同形便于后续合并。存储格式(satelliteOffsets 单 JSON)不变,
已存的值补丁后直接生效。

验证::feature:radar:compileDebugKotlin 与 :feature:radar:test 通过;
:core:domain:test up-to-date(domain 未改动)。
2026-10-05 20:42:08 +08:00
18 changed files with 972 additions and 58 deletions

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,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
}
}
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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