diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt index 5055ab7f..dc34ac3e 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt @@ -19,31 +19,78 @@ package com.rtbishop.look4sat.core.domain.utility import com.rtbishop.look4sat.core.domain.predict.GeoPos +/** + * Converts a Maidenhead locator (QTH grid square) to a GeoPos. + * Supports 6-char (3 pair), 8-char (4 pair) and 10-char (5 pair) locators. + * The returned position is the center of the finest cell encoded by the locator: + * - 6 char: 5' lon x 2.5' lat cell center + * - 8 char: 30" lon x 15" lat cell center + * - 10 char: 1.25" lon x 0.625" lat cell center + */ fun qthToPosition(locator: String): GeoPos? { - val trimmedQth = locator.take(6) + val trimmedQth = locator.trim().uppercase() if (!isValidLocator(trimmedQth)) return null - val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20 - val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10 + val lonFirst = (trimmedQth[0].code - 65) * 20 + val latFirst = (trimmedQth[1].code - 65) * 10 val lonSecond = trimmedQth[2].toString().toInt() * 2 val latSecond = trimmedQth[3].toString().toInt() - val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180 - val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90 - val longitude = (lonFirst + lonSecond + lonThird).round(4) - val latitude = (latFirst + latSecond + latThird).round(4) - return GeoPos(latitude, longitude) + val lonThird = (trimmedQth[4].lowercaseChar().code - 97) / 12.0 + val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0 + var longitude = lonFirst + lonSecond + lonThird - 180 + var latitude = latFirst + latSecond + latThird - 90 + // 8-char extension: 4th pair, digits, 30" lon x 15" lat cells + if (trimmedQth.length >= 8) { + longitude += trimmedQth[6].toString().toInt() / 120.0 + latitude += trimmedQth[7].toString().toInt() / 240.0 + } + // 10-char extension: 5th pair, letters, 1.25" lon x 0.625" lat cells + if (trimmedQth.length >= 10) { + longitude += (trimmedQth[8].lowercaseChar().code - 97) / 2880.0 + latitude += (trimmedQth[9].lowercaseChar().code - 97) / 5760.0 + } + // Offset to the center of the finest encoded cell + when (trimmedQth.length) { + 8 -> { + longitude += 1.0 / 240.0 + latitude += 1.0 / 480.0 + } + 10 -> { + longitude += 1.0 / 5760.0 + latitude += 1.0 / 11520.0 + } + else -> { + longitude += 1.0 / 24.0 + latitude += 1.0 / 48.0 + } + } + return GeoPos(latitude.round(4), longitude.round(4)) } -fun positionToQth(latitude: Double, longitude: Double): String? { +/** + * Converts a GeoPos to a Maidenhead locator (QTH grid square). + * Default precision is 8 characters (4 pairs) giving 30" lon x 15" lat resolution, + * matching common 8-char grid square tools. Pass precision = 6 for the classic + * 5' x 2.5' resolution, or precision = 10 for the finest 1.25" x 0.625" resolution. + */ +fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? { if (!isValidPosition(latitude, longitude)) return null - val newLongitude = if (longitude > 180.0) longitude else longitude + 180 + val newLongitude = longitude + 180 val newLatitude = latitude + 90 - val lonFirst = (65 + (newLongitude / 20)).toInt().toChar() - val latFirst = (65 + (newLatitude / 10)).toInt().toChar() - val lonSecond = ((newLongitude / 2) % 10).toInt() + val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar() + val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar() + val lonSecond = ((newLongitude % 20) / 2).toInt() val latSecond = (newLatitude % 10).toInt() val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar() val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar() - return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird" + val qth = "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird" + if (precision < 8) return qth + val lonFourth = ((newLongitude % (1.0 / 12.0)) * 120).toInt() + val latFourth = ((newLatitude % (1.0 / 24.0)) * 240).toInt() + val qth8 = "$qth$lonFourth$latFourth" + if (precision < 10) return qth8 + val lonFifth = (65 + (newLongitude % (1.0 / 120.0)) * 2880).toInt().toChar().lowercaseChar() + val latFifth = (65 + (newLatitude % (1.0 / 240.0)) * 5760).toInt().toChar().lowercaseChar() + return "$qth8$lonFifth$latFifth" } private fun isValidPosition(lat: Double, lon: Double): Boolean { @@ -51,5 +98,5 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean { } private fun isValidLocator(locator: String): Boolean { - return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex()) + return locator.matches("[a-xA-X]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex()) } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt index 2348dd75..1686a3fe 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt @@ -26,21 +26,36 @@ class QthConverterTest { @Test fun `Given valid QTH returns correct POS`() { var result = qthToPosition("io91VL39FX") - assert(result?.latitude == 51.4792 && result.longitude == -0.2083) + assert(result?.latitude == 51.4999 && result.longitude == -0.2231) result = qthToPosition("gf15vc") assert(result?.latitude == -34.8958 && result.longitude == -56.2083) + // 8-char locators: finer 30" x 15" cell center + result = qthToPosition("io91vl47") + assert(result?.latitude == 51.4896 && result.longitude == -0.2125) + result = qthToPosition("jn58td25") + assert(result?.latitude == 48.1479 && result.longitude == 11.6042) } @Test fun `Given invalid QTH returns null`() { assert(qthToPosition("ZZ00zz") == null) assert(qthToPosition("JN58") == null) + assert(qthToPosition("io9") == null) + assert(qthToPosition("IO91VL7") == null) + assert(qthToPosition("IO91VL4X") == null) } @Test fun `Given valid POS returns correct QTH`() { - assert(positionToQth(51.4878, -0.2146) == "IO91vl") - assert(positionToQth(48.1466, 11.6083) == "JN58td") + // default precision is 8 chars + assert(positionToQth(51.4878, -0.2146) == "IO91vl47") + assert(positionToQth(48.1466, 11.6083) == "JN58td25") + // 6-char precision still available for backwards compatibility + assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl") + assert(positionToQth(48.1466, 11.6083, 6) == "JN58td") + // 10-char precision + assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb") + assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe") } @Test @@ -48,4 +63,23 @@ class QthConverterTest { assert(positionToQth(91.0542, -170.1142) == null) assert(positionToQth(89.0542, -240.1142) == null) } + + @Test + fun `Given boundary POS stays in valid grid`() { + // antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet + assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00") + assert(positionToQth(90.0, 180.0, 8) == "RR00aa00") + assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00") + // roundtrip stability: 8-char roundtrip is stable across a sample of positions + val positions = listOf( + Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093), + Pair(39.9042, 116.4074), Pair(35.6895, 139.6917), Pair(41.714, -72.727) + ) + positions.forEach { (lat, lon) -> + val qth = positionToQth(lat, lon, 8) + val pos = qthToPosition(qth!!) + val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8) + assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" } + } + } } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt index 59254c5c..371b2ab3 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt @@ -97,17 +97,17 @@ fun RadarViewCompose( // Drive the sweep from the animation framework to eliminate state mutation inside the draw block. // Only create the infinite transition when the sweep is enabled — otherwise the canvas would // be invalidated every frame for an effect that isn't visible, wasting GPU and battery. - val sweepDegrees: Float - if (shouldShowSweep) { + val sweepDegrees = if (shouldShowSweep) { val sweepTransition = rememberInfiniteTransition(label = "sweep") - sweepDegrees by sweepTransition.animateFloat( + val animatedSweep by sweepTransition.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)), label = "sweepDegrees" ) + animatedSweep } else { - sweepDegrees = 0f + 0f } val measurer = rememberTextMeasurer() val sunPainter = painterResource(R.drawable.ic_sun)