From 146d47455f89f2dd3c4372f1ce0e86148835cf56 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Tue, 6 Oct 2026 03:18:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(map):=20=E6=B5=B7=E4=B8=8A=E7=BE=A4?= =?UTF-8?q?=E5=B2=9B=E6=8C=89=E6=95=B4=E4=BD=93=E5=8C=85=E7=BB=9C=E5=91=BD?= =?UTF-8?q?=E4=B8=AD=EF=BC=88=E7=82=B9=E5=B2=9B=E9=97=B4=E6=B5=B7=E9=9D=A2?= =?UTF-8?q?=E5=8D=B3=E5=BC=B9=E5=AE=9E=E4=BD=93=E8=AF=A6=E6=83=85=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增第三类命中目标:≥2 分离环且单岛跨度 ≤2.5° 的散布群岛,整体包络即命中区 (±40px 触点余量),点岛链之间的海面归该实体 - 优先级:小实体 80px 目标 > 陆地多边形点内 > 群岛包络;主大陆+零星岛(日/英/印尼) 不进群岛模式,行为不变 - 包络按单一展开经度框计算,反经线群岛(斐济式)不横扫 - 测试 6→10:包络命中、包络内小实体优先、主大陆排除、反经线包络连续 --- .../look4sat/feature/map/AwardHitTest.kt | 101 ++++++++++++++++-- .../look4sat/feature/map/AwardHitTestTest.kt | 79 ++++++++++++++ 2 files changed, 174 insertions(+), 6 deletions(-) diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/AwardHitTest.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/AwardHitTest.kt index ad92b8e4..9c6e15eb 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/AwardHitTest.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/AwardHitTest.kt @@ -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). */ diff --git a/feature/map/src/test/java/com/rtbishop/look4sat/feature/map/AwardHitTestTest.kt b/feature/map/src/test/java/com/rtbishop/look4sat/feature/map/AwardHitTestTest.kt index 5aa01a80..5f6380c8 100644 --- a/feature/map/src/test/java/com/rtbishop/look4sat/feature/map/AwardHitTestTest.kt +++ b/feature/map/src/test/java/com/rtbishop/look4sat/feature/map/AwardHitTestTest.kt @@ -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 + } }