Compare commits

...
Author SHA1 Message Date
atsunatsu 36d091a7e1 chore: bump version to 4.4.6-ba7opf.9.14 2026-09-08 16:44:11 +08:00
atsunatsu 20143cf096 fix(lotw): strip STATE ' // Name' suffix for award stats
LoTW returns STATE as 'CODE // Name' (verified with a real report 2026-09),
e.g. 'GD // Guangdong' (China), '34 // Tottori-ken' (Japan), 'CA // California'
(US). The award calculator matched on the bare CODE, so every mainland China
province (WAPC), all Japan prefectures (WAJA) and US states (WAS) failed —
WAPC only showed HK/MO/TW (entity branch, no STATE needed) and WAJA was empty.

Normalize STATE once at parse time: strip the ' // Name' suffix so the stored
GridQso.state is the clean code consumed by persistence and AwardCalculator.
Add a test covering China/Japan/US formats.
2026-09-08 11:22:24 +08:00
atsunatsu a64f9c80b9 fix(map): split award boundary rings at antimeridian + anchor projection
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.
2026-09-08 11:22:16 +08:00
4 changed files with 128 additions and 29 deletions

No files matched your search

@@ -212,7 +212,7 @@ class LoTWRepository : ILoTWRepository {
line.startsWith("<CQZ:") ->
cqz = adifValue(line).toIntOrNull()
line.startsWith("<STATE:") ->
state = adifValue(line).trim().ifBlank { null }
state = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
// VUCC_GRIDS holds a comma-separated list of grids
// ("EN52en,EN53fa"), up to four for contacts spanning
@@ -231,6 +231,16 @@ class LoTWRepository : ILoTWRepository {
private fun adifValue(line: String): String =
line.substringAfter('>').substringBefore("E<").trim()
/**
* LoTW returns STATE as "CODE // NAME" (e.g. "HB // Hubei" for China,
* "34 // Tottori-ken" for Japan, "CA // California" for the US). The award
* statistics match on the short CODE only (China 2-letter province pinyin,
* Japan 2-digit prefecture, US 2-letter state), so strip the " // NAME"
* suffix here once, storing the clean code for every downstream consumer
* (persistence, AwardCalculator).
*/
private fun normalizeState(raw: String): String = raw.substringBefore(" // ").trim()
/** "20260820" + "1130" (or "113000") -> UTC epoch ms; 0 when unparseable. */
private fun adifTimestampToEpoch(date: String, time: String): Long = try {
val d = date.trim()
@@ -156,11 +156,13 @@ class LoTWRepositoryTest {
@Test
fun parseQsosExtractsAwardFields() {
// Award statistics rely on the DXCC/CQZ/STATE fields coming straight
// from LoTW's qso_qsldetail report (STATE depends on DXCC).
// from LoTW's qso_qsldetail report (STATE depends on DXCC). LoTW
// writes STATE as "CODE // NAME" (verified with a real report 2026-09);
// the parser must strip the name suffix and keep the bare code.
val qso = "<CALL:5>BG7XYZ\n" +
"<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
"<DXCC:3>318\n<COUNTRY:5>CHINA\n<CQZ:2>24\n<STATE:2>GD\n" +
"<DXCC:3>318\n<COUNTRY:5>CHINA\n<CQZ:2>24\n<STATE:13>GD // Guangdong\n" +
"<GRIDSQUARE:4>OL62\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso))!!
val parsed = result["OL62"]!!.first()
@@ -170,6 +172,21 @@ class LoTWRepositoryTest {
assertEquals("GD", parsed.state)
}
@Test
fun parseQsosStripsStateNameSuffixForAllEntities() {
// Japan prefecture: "34 // Tottori-ken" -> "34" (WAJA matching needs
// the 2-digit code). US states also arrive as "CODE // Name".
val jp = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<DXCC:3>339\n<COUNTRY:7>JAPAN\n" +
"<STATE:18>34 // Tottori-ken\n<GRIDSQUARE:4>PM95\n<EOR>\n"
val us = "<CALL:5>K1ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<DXCC:3>291\n<COUNTRY:12>UNITED STATES\n" +
"<STATE:22>CA // California\n<GRIDSQUARE:4>EM40\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(jp, us))!!
assertEquals("34", result["PM95"]!!.first().state)
assertEquals("CA", result["EM40"]!!.first().state)
}
@Test
fun parseQsosLeavesAwardFieldsNullWhenAbsent() {
val qso = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
@@ -158,8 +158,6 @@ class AwardBoundaryOverlay : Overlay() {
/** Bounding boxes (computed on region assignment) for cheap culling. */
private val regionBounds = mutableListOf<DoubleArray>() // [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<DoubleArray>): List<List<DoubleArray>> {
val n = ring.size
if (n == 0) return emptyList()
val pts = Array(n) { doubleArrayOf(normalizeLon(ring[it][0]), ring[it][1]) }
val segments = mutableListOf<MutableList<DoubleArray>>()
var cur = mutableListOf<DoubleArray>()
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<DoubleArray>().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<DoubleArray>,
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
+1 -1
View File
@@ -2,7 +2,7 @@
#noinspection UnusedVersionCatalogEntry
appVersionCode = "464"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.6-ba7opf.9.13"
appVersionName = "4.4.6-ba7opf.9.14"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry