From a64f9c80b9af1b9e06db10425b8f932734482511 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Tue, 8 Sep 2026 11:22:16 +0800 Subject: [PATCH] fix(map): split award boundary rings at antimeridian + anchor projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DXCC (Russia/Fiji/Antarctica) and WAZ (Pacific CQ zones 1/19/31/32) polygon rings cross ±180. The old overlay normalized every vertex to [-180,180) independently, folding far-side vertices onto the opposite screen edge and drawing a spurious line sweeping across the whole map (esp. at low zoom: DXCC horizontal/vertical lines, WAZ extra long edges). Split each ring into contiguous segments at the antimeridian, then project each segment anchored on its first vertex with continuous longitude offsets (no per-vertex fold). Ring geometry stays intact while panning/zooming. --- .../feature/map/AwardBoundaryOverlay.kt | 122 ++++++++++++++---- 1 file changed, 97 insertions(+), 25 deletions(-) diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/AwardBoundaryOverlay.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/AwardBoundaryOverlay.kt index 222bc17a..4d44710b 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/AwardBoundaryOverlay.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/AwardBoundaryOverlay.kt @@ -158,8 +158,6 @@ class AwardBoundaryOverlay : Overlay() { /** Bounding boxes (computed on region assignment) for cheap culling. */ private val regionBounds = mutableListOf() // [minLon,minLat,maxLon,maxLat] - private var canvasWidthPx = 1080f - override fun setEnabled(on: Boolean) { super.setEnabled(on) } @@ -168,7 +166,6 @@ class AwardBoundaryOverlay : Overlay() { if (shadow || !isEnabled || regions.isEmpty()) return val projection = mapView.projection val zoom = mapView.zoomLevelDouble - canvasWidthPx = canvas.width.toFloat() val worldWidthPx = 256.0 * Math.pow(2.0, zoom) // Visible bounding box (same anchor-on-center approach as the grid overlay). @@ -197,29 +194,20 @@ class AwardBoundaryOverlay : Overlay() { val path = Path() var pathHasPoints = false for (ring in region.rings) { - var penDown = false - var prevX = 0f - var prevY = 0f - var lastLon = Double.NaN - for (pt in ring) { - val lon = pt[0] - val lat = pt[1] - // Split rings crossing the antimeridian (|dLon| > 180°). - if (penDown && !lastLon.isNaN() && abs(lon - lastLon) > 180.0) { - penDown = false - } - val x = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue - val y = projectionToY(projection, lat) ?: continue - if (!penDown) { - path.moveTo(x, y) - penDown = true - } else { - path.lineTo(x, y) - } - prevX = x; prevY = y; lastLon = lon - pathHasPoints = true + // Split each ring into consecutive segments at the antimeridian + // (±180°) and draw each as its own sub-path. Naively normalizing + // every vertex to [-180,180) folds vertices that sit on the + // "far" side of ±180 onto the opposite screen edge, drawing a + // spurious line that sweeps across the whole map (Russia/Fiji/ + // Antarctica for DXCC, the Pacific CQ zones for WAZ). Splitting + // keeps each segment contiguous. Each segment then gets ONE + // per-segment longitude offset (in whole 360° turns) chosen to + // sit closest to the view center, so vertices far from the + // center are not folded to ±180 the wrong way at low zoom. + for (segment in splitRingAtAntimeridian(ring)) { + if (segment.isEmpty()) continue + pathHasPoints = traceSegment(path, segment, projection, centerLon, worldWidthPx) || pathHasPoints } - if (penDown) path.close() } if (!pathHasPoints) continue @@ -240,6 +228,90 @@ class AwardBoundaryOverlay : Overlay() { } } + /** + * Splits a ring's vertices into consecutive segments that do NOT cross the + * antimeridian. Vertices are first normalized to [-180,180); whenever two + * consecutive vertices differ by more than 180° in longitude (the ring + * wraps over ±180), the segment is closed there and a new segment starts. + * + * A ring that crosses the antimeridian (e.g. Russia's eastern tip, or the + * Pacific CQ zones whose raw lon runs past ±180) becomes two segments, each + * kept geometrically contiguous so no spurious long edge is drawn. + */ + private fun splitRingAtAntimeridian(ring: List): List> { + val n = ring.size + if (n == 0) return emptyList() + val pts = Array(n) { doubleArrayOf(normalizeLon(ring[it][0]), ring[it][1]) } + val segments = mutableListOf>() + var cur = mutableListOf() + cur.add(pts[0]) + for (i in 1 until n) { + val prevLon = pts[i - 1][0] + val lon = pts[i][0] + if (abs(lon - prevLon) > 180.0) { + // Crossed the antimeridian: start a fresh segment. + if (cur.size >= 2) segments.add(cur) + cur = mutableListOf() + } + cur.add(pts[i]) + } + if (cur.size >= 2) segments.add(cur) + // A fully-winding ring may have collapsed into a single segment after + // normalization (no actual crossing between consecutive vertices); in + // that case fall back to the whole ring as one segment. + if (segments.isEmpty()) segments.add(mutableListOf().also { it.addAll(pts) }) + return segments + } + + /** + * Projects [segment] into [path], keeping the segment's geometry + * contiguous on screen. + * + * Longitudes are normalized once when the ring is split, and every vertex + * of a segment lies within 180° of its neighbours (a segment never crosses + * the antimeridian). We anchor on the segment's FIRST vertex: its screen x + * comes from [projectionToX] (correctly placed relative to the view + * center), and every other vertex is offset by the *continuous* longitude + * difference from that anchor, scaled by worldWidthPx. Because the whole + * segment stays on the "near" side of ±180 relative to its anchor, no + * vertex is folded to the opposite screen edge — that fold is what drew + * the spurious long lines across the map (Russia/Fiji/Antarctica for + * DXCC, the Pacific CQ zones for WAZ). + * + * @return true if at least one point was placed in the path + */ + private fun traceSegment( + path: Path, + segment: List, + projection: Projection, + centerLon: Double, + worldWidthPx: Double + ): Boolean { + if (segment.isEmpty()) return false + val anchorLon = segment[0][0] + val anchorX = projectionToX(projection, anchorLon, centerLon, worldWidthPx) ?: return false + var penDown = false + var any = false + for (i in segment.indices) { + val pt = segment[i] + // Continuous longitude difference from the anchor — no per-vertex + // fold. The segment spans < 180° (guaranteed by the antimeridian + // split), so delta stays within a single turn of the anchor. + val delta = pt[0] - anchorLon + val x = anchorX + (delta / 360.0 * worldWidthPx).toFloat() + val y = projectionToY(projection, pt[1]) ?: continue + if (!penDown) { + path.moveTo(x, y) + penDown = true + } else { + path.lineTo(x, y) + } + any = true + } + if (penDown) path.close() + return any + } + /** X pixel for a longitude (Mercator X is linear in longitude). */ private fun projectionToX(projection: Projection, lon: Double, centerLon: Double, worldWidthPx: Double): Float? { var delta = lon - centerLon