mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-05 20:17:29 +00:00
feat(map): 海上群岛按整体包络命中(点岛间海面即弹实体详情)
- 新增第三类命中目标:≥2 分离环且单岛跨度 ≤2.5° 的散布群岛,整体包络即命中区 (±40px 触点余量),点岛链之间的海面归该实体 - 优先级:小实体 80px 目标 > 陆地多边形点内 > 群岛包络;主大陆+零星岛(日/英/印尼) 不进群岛模式,行为不变 - 包络按单一展开经度框计算,反经线群岛(斐济式)不横扫 - 测试 6→10:包络命中、包络内小实体优先、主大陆排除、反经线包络连续
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user