feat(map): 海上群岛按整体包络命中(点岛间海面即弹实体详情)

- 新增第三类命中目标:≥2 分离环且单岛跨度 ≤2.5° 的散布群岛,整体包络即命中区
  (±40px 触点余量),点岛链之间的海面归该实体
- 优先级:小实体 80px 目标 > 陆地多边形点内 > 群岛包络;主大陆+零星岛(日/英/印尼)
  不进群岛模式,行为不变
- 包络按单一展开经度框计算,反经线群岛(斐济式)不横扫
- 测试 6→10:包络命中、包络内小实体优先、主大陆排除、反经线包络连续
This commit is contained in:
atsunatsu committed 2026-10-06 03:18:57 +08:00
1 parent a7d99862d9
commit 146d47455f
2 files changed
+174 -6

No files matched your search

@@ -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
}
}