Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -882,46 +882,65 @@ private fun AwardProgressBar(
}

// ---------------------------------------------------------------------------
// Grid square coverage heatmap (18x10 field grid: AA..RR x 00..99 at field level)
// Grid square coverage heatmap (18x18 field grid: AA..RR at Maidenhead field level)
// ---------------------------------------------------------------------------

// A Maidenhead field designator is two letters: the longitude field (A..R, 18
// divisions of 360° = 20° each) followed by the latitude field (A..R, 18
// divisions of 180° = 10° each). BOTH axes span the full A..R range — the
// latitude field is a letter, not a digit — so the coverage grid is 18x18 (324
// cells). Iterating fewer latitude rows silently drops every field north of +10°
// (latitude field K onward: essentially all of North America, Europe, and Japan),
// so those worked grids could never highlight.
internal const val GRID_HEATMAP_COLS = 18
internal const val GRID_HEATMAP_ROWS = 18

/** One coverage-grid cell: its Maidenhead field (e.g. "FN") and whether it was worked. */
internal data class GridHeatmapCell(val field: String, val isWorked: Boolean)

/**
* The two-letter field designators actually worked, derived from each record's
* grid (first two chars, upper-cased). Grids shorter than two chars are skipped.
*/
internal fun workedGridFields(grids: List<String?>): Set<String> =
grids.mapNotNull { grid ->
if (grid != null && grid.length >= 2) grid.substring(0, 2).uppercase() else null
}.toSet()
Comment on lines +905 to +908

/**
* The full 18x18 coverage grid, row-major (row = latitude field A..R, col =
* longitude field A..R), each cell flagged worked against [workedFields].
*/
internal fun buildGridHeatmapCells(workedFields: Set<String>): List<List<GridHeatmapCell>> =
(0 until GRID_HEATMAP_ROWS).map { row ->
(0 until GRID_HEATMAP_COLS).map { col ->
val field = "${'A' + col}${'A' + row}"
GridHeatmapCell(field, field in workedFields)
}
}

@Composable
private fun GridSquareHeatmap(
records: List<QSLCallsignRecord>,
modifier: Modifier = Modifier,
progress: Float = 1f,
) {
// Build set of worked 2-char field designators (e.g., "FN", "JO")
val workedFields = remember(records) {
records.mapNotNull { record ->
val grid = record.grid
if (grid != null && grid.length >= 2) {
grid.substring(0, 2).uppercase()
} else null
}.toSet()
val cells = remember(records) {
buildGridHeatmapCells(workedGridFields(records.map { it.grid }))
}

val cols = 18 // A..R
val rows = 10 // 0..9 (latitude bands, typically A-R letters mapped, but for the field
// grid we show longitude letters across, latitude digits down)

GlassCard(modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(12.dp),
) {
for (row in 0 until rows) {
cells.forEachIndexed { row, rowCells ->
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
for (col in 0 until cols) {
val fieldLon = ('A' + col)
val fieldLat = ('A' + row)
val field = "$fieldLon$fieldLat"
val isWorked = field in workedFields

for (cell in rowCells) {
val cellColor = when {
isWorked -> Signal.copy(alpha = 0.7f * progress.coerceIn(0f, 1f))
cell.isWorked -> Signal.copy(alpha = 0.7f * progress.coerceIn(0f, 1f))
else -> BgSurface3.copy(alpha = 0.4f)
}

Expand All @@ -933,7 +952,7 @@ private fun GridSquareHeatmap(
)
}
}
if (row < rows - 1) Spacer(modifier = Modifier.height(2.dp))
if (row < cells.size - 1) Spacer(modifier = Modifier.height(2.dp))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package radio.ks3ckc.ft8af.ui.logbook

import com.google.common.truth.Truth.assertThat
import org.junit.Test

/**
* Unit tests for the pure logic extracted from the logbook's [GridSquareHeatmap]:
* [workedGridFields] (records -> worked field set) and [buildGridHeatmapCells]
* (the 18x18 Maidenhead field coverage grid).
*
* The regression these guard: the coverage grid used to iterate only 10 latitude
* rows (A..J = latitudes <= +10°N), so every worked field north of that — all of
* North America, Europe, and Japan — was never generated and could never
* highlight. Both Maidenhead field axes span A..R (18), so the grid must be
* 18x18. All plain-JVM (no Robolectric): the helpers take Strings/collections.
*/
class GridSquareHeatmapTest {

@Test
fun gridIs18x18WithFullFieldCoverage() {
val cells = buildGridHeatmapCells(emptySet())
assertThat(cells).hasSize(18)
cells.forEach { row -> assertThat(row).hasSize(18) }
// 324 distinct field designators AA..RR.
val fields = cells.flatten().map { it.field }
assertThat(fields).hasSize(324)
assertThat(fields.toSet()).hasSize(324)
assertThat(fields).containsAtLeast("AA", "RR", "FN", "JO")
}

@Test
fun northernHemisphereFieldIsGeneratedAndFlagged() {
// "FN" (New England — latitude field 'N', index 13) lies above +10°N, the
// exact band the old 10-row grid dropped. It must now exist AND flag worked.
val cells = buildGridHeatmapCells(setOf("FN"))
val fn = cells.flatten().singleOrNull { it.field == "FN" }
assertThat(fn).isNotNull()
assertThat(fn!!.isWorked).isTrue()
}

@Test
fun highLatitudeFieldsAreAllPresent() {
val fields = buildGridHeatmapCells(emptySet()).flatten().map { it.field }.toSet()
// Every longitude field paired with the northernmost latitude letters.
for (lon in 'A'..'R') {
assertThat(fields).contains("${lon}R") // +80°N band
assertThat(fields).contains("${lon}K") // just above +10°N
}
}

@Test
fun onlyWorkedFieldsAreFlagged() {
val cells = buildGridHeatmapCells(setOf("FN", "JO"))
val worked = cells.flatten().filter { it.isWorked }.map { it.field }.toSet()
assertThat(worked).containsExactly("FN", "JO")
}

@Test
fun workedGridFieldsTakesFirstTwoCharsUpperCased() {
val fields = workedGridFields(listOf("fn31pr", "JO22", "IO91wm"))
assertThat(fields).containsExactly("FN", "JO", "IO")
}

@Test
fun workedGridFieldsSkipsNullAndTooShortGrids() {
val fields = workedGridFields(listOf(null, "", "F", "FN"))
assertThat(fields).containsExactly("FN")
}

@Test
fun workedGridFieldsDedupes() {
assertThat(workedGridFields(listOf("FN31", "FN42", "fn20"))).containsExactly("FN")
}
}
Loading