Compare commits

...
Author SHA1 Message Date
atsunatsu 917788041c build: release version 4.4.8-ba7opf.11 (575) 2026-10-06 14:16:29 +08:00
atsunatsu 58bc96c441 fix(map): DXCC 领土缺口补齐、父国填色挖洞、挪威斯瓦尔巴粗环删除
- 领土缺口: Natural Earth 把 Somaliland/N. Cyprus 画成独立图斑, 6O 索马里与
  5B 塞浦路斯的北部领土此前无任何边界(不画框/不填绿); 按 NE 1:110m 并入父国
  环并溶解内部界线(单例外框), 与埃塞/吉布提/肯尼亚零重叠
- 父国填色挖洞: 新增 fill_rings(父领土−子实体, shapely 差集), worked 填充改
  偶奇挖洞, 通父国不再涂绿独立子实体: 南非→莱索托, 中国→香港, 马来西亚→新加坡,
  法国→安道尔/摩纳哥, 意大利→圣马力诺/梵蒂冈, 奥+瑞→列支敦士登, 西班牙→
  直布罗陀/安道尔, 古巴→关塔那摩湾; 描边/命中/其他奖状资产不变
- 双线修复: 删除 Norway 残留的 3 个斯瓦尔巴粗环(NE Norway 几何自带), 斯瓦尔巴
  只剩精环单线, 通 LA 不再误涂绿 JW
- DxccAssetTest +3 守卫(合并领土探针/填色挖洞钉对/Norway 无 >73°N 残环)
2026-10-06 14:15:53 +08:00
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
6 changed files with 321 additions and 22 deletions

No files matched your search

+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
@@ -22,6 +22,7 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import org.json.JSONArray
import org.json.JSONObject
import org.osmdroid.views.MapView
import org.osmdroid.views.Projection
@@ -48,6 +49,12 @@ data class AwardRegion(
val labelLon: Double,
val labelLat: Double,
val rings: List<List<DoubleArray>>,
/** Optional fill geometry with separate child entities knocked out as
* even-odd holes (e.g. Lesotho inside South Africa, Hong Kong inside
* China). Used only for the worked green fill so a worked parent never
* greens a different DXCC entity's territory; [rings] stay the stroke /
* hit-test source. Empty means "fill = rings". */
val fillRings: List<List<DoubleArray>> = emptyList(),
val forceLabel: Boolean = false
)
@@ -61,6 +68,20 @@ object AwardBoundaryData {
private val cache = mutableMapOf<AwardAsset, List<AwardRegion>>()
private fun parseRings(array: JSONArray): List<List<DoubleArray>> {
val rings = mutableListOf<List<DoubleArray>>()
for (r in 0 until array.length()) {
val ring = array.optJSONArray(r) ?: continue
val points = mutableListOf<DoubleArray>()
for (p in 0 until ring.length()) {
val pt = ring.optJSONArray(p) ?: continue
if (pt.length() >= 2) points.add(doubleArrayOf(pt.getDouble(0), pt.getDouble(1)))
}
if (points.size >= 3) rings.add(points)
}
return rings
}
enum class AwardAsset(val fileName: String) {
WAPC("wapc.json"),
WAJA("waja.json"),
@@ -80,16 +101,7 @@ object AwardBoundaryData {
for (i in 0 until array.length()) {
val o = array.optJSONObject(i) ?: continue
val ringsArray = o.optJSONArray("rings") ?: continue
val rings = mutableListOf<List<DoubleArray>>()
for (r in 0 until ringsArray.length()) {
val ring = ringsArray.optJSONArray(r) ?: continue
val points = mutableListOf<DoubleArray>()
for (p in 0 until ring.length()) {
val pt = ring.optJSONArray(p) ?: continue
if (pt.length() >= 2) points.add(doubleArrayOf(pt.getDouble(0), pt.getDouble(1)))
}
if (points.size >= 3) rings.add(points)
}
val rings = parseRings(ringsArray)
if (rings.isEmpty()) continue
list.add(
AwardRegion(
@@ -99,6 +111,7 @@ object AwardBoundaryData {
labelLon = o.optDouble("label_lon", 0.0),
labelLat = o.optDouble("label_lat", 0.0),
rings = rings,
fillRings = o.optJSONArray("fill_rings")?.let { parseRings(it) } ?: emptyList(),
forceLabel = o.optBoolean("force_label", false)
)
)
@@ -238,6 +251,14 @@ class AwardBoundaryOverlay : Overlay() {
val tiny = max(regionW, regionH) <= AwardHitTest.MIN_SHAPE_PX
val path = Path()
// The worked fill uses fillRings when present: the award asset stores
// fill geometry with separate child entities (Lesotho in South Africa,
// Hong Kong in China, ...) knocked out as even-odd holes, so working a
// parent never greens a different DXCC entity's territory. The stroke
// keeps using the plain rings — hole borders coincide with the child
// entity's own outline and must not be drawn twice.
val fillRings = region.fillRings.ifEmpty { region.rings }
val fillPath = if (worked) Path().apply { fillType = Path.FillType.EVEN_ODD } else null
var pathHasPoints = false
for (ring in region.rings) {
// Split each ring into consecutive segments at the antimeridian
@@ -262,9 +283,19 @@ class AwardBoundaryOverlay : Overlay() {
pathHasPoints = traceSegment(path, segment, projection, centerLon, worldWidthPx, closeRing) || pathHasPoints
}
}
fillPath?.let { fp ->
for (ring in fillRings) {
val segments = splitRingAtAntimeridian(ring)
val closeRing = segments.size == 1
for (segment in segments) {
if (segment.isEmpty()) continue
traceSegment(fp, segment, projection, centerLon, worldWidthPx, closeRing)
}
}
}
if (!pathHasPoints && !tiny) continue
if (worked) canvas.drawPath(path, workedPaint)
if (worked) fillPath?.let { canvas.drawPath(it, workedPaint) }
linePaint.strokeWidth = if (selected) LINE_WIDTH_SELECTED else LINE_WIDTH
canvas.drawPath(path, linePaint)
linePaint.strokeWidth = LINE_WIDTH
@@ -28,7 +28,7 @@ import kotlin.math.pow
* 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:
* 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
@@ -36,6 +36,14 @@ import kotlin.math.pow
* - 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 {
@@ -49,6 +57,12 @@ object AwardHitTest {
* 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
@@ -69,6 +83,8 @@ object AwardHitTest {
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).
@@ -83,20 +99,93 @@ object AwardHitTest {
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 = regions[i]
bestSmall = region
bestSmallDist = dist
}
} else if (w * h < bestPolyArea && contains(regions[i], tapLat, tapLon)) {
bestPoly = regions[i]
bestPolyArea = w * h
} 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
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). */
@@ -123,4 +123,83 @@ class AwardHitTestTest {
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
}
}
@@ -18,8 +18,10 @@
package com.rtbishop.look4sat.feature.map
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -60,8 +62,10 @@ class DxccAssetTest {
first { it.getInt("code") == code }
/** Even-odd ray casting across all rings of a region (rings are disjoint parts). */
private fun regionContains(region: JSONObject, lon: Double, lat: Double): Boolean {
val rings = region.getJSONArray("rings")
private fun regionContains(region: JSONObject, lon: Double, lat: Double): Boolean =
ringsContain(region.getJSONArray("rings"), lon, lat)
private fun ringsContain(rings: JSONArray, lon: Double, lat: Double): Boolean {
var inside = false
for (r in 0 until rings.length()) {
val ring = rings.getJSONArray(r)
@@ -220,4 +224,100 @@ class DxccAssetTest {
!regionContains(regions.byCode(c.outside), c.lon, c.lat))
}
}
@Test
fun `merged map units are framed by their DXCC parent`() {
// Natural Earth draws Somaliland and N. Cyprus as separate map units.
// Both belong to their DXCC entity (6O Somalia / 5B Cyprus) and are
// merged into the parent rings — without the merge the northern
// territories had no boundary at all (nothing drawn, never greened).
val regions = loadRegions()
val checks = listOf(
Triple("Hargeisa", 44.06 to 9.56, 232),
Triple("Berbera", 45.02 to 10.44, 232),
Triple("Garowe", 48.48 to 8.40, 232),
Triple("Kyrenia", 33.32 to 35.34, 215),
Triple("Nicosia", 33.38 to 35.19, 215)
)
for ((city, pos, code) in checks) {
assertTrue("$city should be inside $code",
regionContains(regions.byCode(code), pos.first, pos.second))
}
}
@Test
fun `Norway keeps only its mainland ring`() {
// Norway's Natural Earth geometry carries coarse Svalbard rings (7-17 pts).
// Svalbard (259) is a separate DXCC entity with its own detailed rings, so
// the coarse copies were deleted — they drew every island twice (a simple
// and a coast-fitting outline) and greening Norway also greened JW.
val norway = loadRegions().byCode(266)
val rings = norway.getJSONArray("rings")
assertEquals("Norway ring count", 1, rings.length())
for (r in 0 until rings.length()) {
val ring = rings.getJSONArray(r)
for (p in 0 until ring.length()) {
val lat = ring.getJSONArray(p).getDouble(1)
assertTrue("Norway vertex at $lat — Svalbard ring leftover", lat <= 73.0)
}
}
}
@Test
fun `worked fill knockouts cover the vetted parent child pairs`() {
// fill_rings = parent territory minus separate child DXCC entities, so a
// worked parent (e.g. South Africa 462) never greens a child entity
// (Lesotho 432) that has its own code. Pairs were vetted geometrically
// (child area >=50% inside the parent); this test pins them so future
// asset edits re-trigger the review.
val expected = mapOf(
462 to setOf(432), // South Africa - Lesotho
318 to setOf(321), // China - Hong Kong
299 to setOf(381), // Malaysia - Singapore
227 to setOf(203, 260), // France - Andorra, Monaco
248 to setOf(278, 295), // Italy - San Marino, Vatican
206 to setOf(251), // Austria - Liechtenstein
287 to setOf(251), // Switzerland - Liechtenstein
281 to setOf(203, 233), // Spain - Andorra, Gibraltar
70 to setOf(105) // Cuba - Guantanamo Bay
)
val regions = loadRegions()
val withFill = regions.filter { it.has("fill_rings") }.map { it.getInt("code") }.toSet()
assertEquals("regions carrying fill_rings", expected.keys, withFill)
for ((parentCode, childCodes) in expected) {
val parent = regions.byCode(parentCode)
val fillRings = parent.getJSONArray("fill_rings")
assertTrue("${parent.getString("name")}: empty fill_rings", fillRings.length() >= 1)
// The parent's own anchor must survive the knockout (no over-cutting).
val pLon = parent.getDouble("label_lon")
val pLat = parent.getDouble("label_lat")
assertTrue("${parent.getString("name")}: anchor lost in fill",
ringsContain(fillRings, pLon, pLat))
for (childCode in childCodes) {
val child = regions.byCode(childCode)
val childRings = child.getJSONArray("rings")
// Probes: the label anchor plus each ring's centroid. Some anchors
// drift outside their own ring (Guantanamo Bay), so a probe only
// counts when it really sits inside the child's own geometry.
val probes = mutableListOf(
child.getDouble("label_lon") to child.getDouble("label_lat")
)
for (r in 0 until childRings.length()) {
val ring = childRings.getJSONArray(r)
var sx = 0.0; var sy = 0.0
for (p in 0 until ring.length()) {
sx += ring.getJSONArray(p).getDouble(0)
sy += ring.getJSONArray(p).getDouble(1)
}
probes.add(sx / ring.length() to sy / ring.length())
}
for ((cLon, cLat) in probes) {
if (regionContains(child, cLon, cLat) && regionContains(parent, cLon, cLat)) {
assertFalse("${parent.getString("name")} fill still covers ${child.getString("name")}",
ringsContain(fillRings, cLon, cLat))
}
}
}
}
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "573"
appVersionCode = "575"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.8-ba7opf.10"
appVersionName = "4.4.8-ba7opf.11"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry