Skip to content
Merged
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
71 changes: 59 additions & 12 deletions ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1325,13 +1325,17 @@ private fun AzimuthalMapCanvas(
// Land — Natural Earth 110m vector outlines projected via azProject
landRings?.let { rings -> drawAzimuthalLand(rings, opLat, opLon, cx, cy, r, scale, panX, panY) }

// National borders over the land; US state borders once zoomed in.
// National borders over the land; US state borders fade in with the basemap's density.
countryRings?.let { rings ->
drawAzimuthalRings(rings, opLat, opLon, cx, cy, r, scale, panX, panY, CountryBorderColor, 0.7f)
}
if (showStateBorders(scale)) {
val stateAlpha = stateBorderAlpha(azimuthalPxPerLonDegree(r, scale))
if (stateAlpha > 0f) {
stateRings?.let { rings ->
drawAzimuthalRings(rings, opLat, opLon, cx, cy, r, scale, panX, panY, StateBorderColor, 0.5f)
drawAzimuthalRings(
rings, opLat, opLon, cx, cy, r, scale, panX, panY,
StateBorderColor.copy(alpha = StateBorderColor.alpha * stateAlpha), 0.5f,
)
}
}

Expand Down Expand Up @@ -1537,10 +1541,15 @@ private fun StandardMapCanvas(
// Land — Natural Earth 110m vector outlines (drawn once polygons are loaded)
landRings?.let { rings -> drawWorldLand(rings, vp) }

// National borders over the land; US state borders once zoomed in.
// National borders over the land; US state borders fade in with the basemap's density.
countryRings?.let { rings -> drawEquirectRings(rings, vp, CountryBorderColor, 0.7f) }
if (showStateBorders(scale)) {
stateRings?.let { rings -> drawEquirectRings(rings, vp, StateBorderColor, 0.5f) }
val stateAlpha = stateBorderAlpha(vp.pxPerLonDegree)
if (stateAlpha > 0f) {
stateRings?.let { rings ->
drawEquirectRings(rings, vp, StateBorderColor.copy(
alpha = StateBorderColor.alpha * stateAlpha,
), 0.5f)
}
}

// Lat/lon grid (over land so it stays visible across continents)
Expand Down Expand Up @@ -1825,14 +1834,52 @@ private fun DrawScope.drawWorldLand(rings: List<FloatArray>, vp: EquirectViewpor
}

// Border strokes drawn over the dissolved land layer so the basemap reads as a
// real bordered map. National borders are always on; US state borders only when
// zoomed in enough to make sense of them (they'd be noise at world scale).
// real bordered map. National borders are always on; US state borders fade in with the map's
// detail budget (see stateBorderAlpha) so a US operator sees their state lines at the default
// view instead of only after pinching past a hard zoom gate.
private val CountryBorderColor = Color(0x7B94A3B8)
private val StateBorderColor = Color(0x4694A3B8)
internal const val STATE_BORDER_ZOOM = 3f
private val StateBorderColor = Color(0x6094A3B8)

/** At/below this many longitude px per degree the state layer would be mush, so it is hidden. */
internal const val STATE_BORDER_MIN_PX_PER_DEG = 3f

/**
* At/above this many longitude px per degree the state layer is drawn at full strength. Tuned on
* the real map card, which is ~1000x1140 px on a 1080p phone: the standard projection's default
* "whole world" view lands at ~6.3 px per degree, where the lower 48 are ~370 px across and their
* borders read cleanly, so the whole standard projection is at full strength.
*/
internal const val STATE_BORDER_FULL_PX_PER_DEG = 6f

/** State borders are decluttered until the map is zoomed past [STATE_BORDER_ZOOM]. */
internal fun showStateBorders(scale: Float): Boolean = scale >= STATE_BORDER_ZOOM
/**
* Opacity multiplier (0..1) for the US state border layer at a basemap density of
* [pxPerLonDegree] longitude pixels per degree — 0 below [STATE_BORDER_MIN_PX_PER_DEG], 1 at/above
* [STATE_BORDER_FULL_PX_PER_DEG], linear between.
*
* Replaces a hard `scale >= 3` gate that kept the layer off in exactly the view a US operator
* looks at: with a grid set and no decodes yet the map auto-frames the operator at scale ~2.9,
* a hair under the gate, so the state lines never appeared.
*
* Zoom is the wrong measure anyway. The map COVERs its card, so at the same `scale` the standard
* projection spends far more pixels per degree than the azimuthal disc, which squeezes the whole
* globe into the card width (~3 px per degree — where state outlines really are noise). Density is
* what decides whether the layer helps, and fading rather than snapping keeps a pinch from popping
* a whole layer into existence.
*/
internal fun stateBorderAlpha(pxPerLonDegree: Float): Float = when {
pxPerLonDegree <= STATE_BORDER_MIN_PX_PER_DEG -> 0f
pxPerLonDegree >= STATE_BORDER_FULL_PX_PER_DEG -> 1f
else -> (pxPerLonDegree - STATE_BORDER_MIN_PX_PER_DEG) /
(STATE_BORDER_FULL_PX_PER_DEG - STATE_BORDER_MIN_PX_PER_DEG)
}

/**
* Basemap density (longitude px per degree) of the azimuthal disc: the disc's radius spans a
* quarter of the globe's circumference — [azProject] puts the antipode (180° away) at the rim —
* so `r * scale` pixels cover 180°.
*/
internal fun azimuthalPxPerLonDegree(discRadiusPx: Float, scale: Float): Float =
discRadiusPx * scale / 180f

/** Stroke a set of lon/lat rings through the equirectangular viewport (borders, no fill). */
private fun DrawScope.drawEquirectRings(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ internal class EquirectViewport(
val worldPxW: Float = baseUniform * 2f * userScale
val worldPxH: Float = baseUniform * 1f * userScale

/**
* Longitude pixels per degree at the current zoom — how much screen the map spends on a
* degree of longitude. The world is [worldPxW] px wide and spans 360°, so this is the honest
* measure of how much detail the basemap can carry (the raw [userScale] is not: COVER already
* blows the world up past the canvas at zoom 1, and by different amounts on a tall phone and a
* wide tablet). Drives the state-border fade — see `stateBorderAlpha`.
*/
val pxPerLonDegree: Float get() = worldPxW / 360f

/** Project a normalized point (from [equirectProject]) to canvas pixels. */
fun project(p: ProjectedPoint): Offset {
val cx = canvasW / 2f
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import kotlin.math.abs

/**
* Pure unit tests for the Map redesign (concept 4c) logic: layer-chip
* visibility, state-border zoom gating, the gray-line info pill countdown, and
* visibility, state-border fade, the gray-line info pill countdown, and
* the "Me" recenter pan. No Android/Compose runtime is touched.
*/
class MapRedesignTest {
Expand Down Expand Up @@ -39,14 +39,54 @@ class MapRedesignTest {
assertThat(isStationVisible(isWorked = true, decodesOn = false, workedOn = false)).isFalse()
}

// ---- state-border zoom gate ----------------------------------------------
// ---- state-border fade ---------------------------------------------------

@Test
fun stateBorders_hiddenUntilZoomThreshold() {
assertThat(showStateBorders(1f)).isFalse()
assertThat(showStateBorders(STATE_BORDER_ZOOM - 0.01f)).isFalse()
assertThat(showStateBorders(STATE_BORDER_ZOOM)).isTrue()
assertThat(showStateBorders(8f)).isTrue()
fun stateBorders_hiddenWhenBasemapIsTooCoarse() {
assertThat(stateBorderAlpha(0f)).isEqualTo(0f)
assertThat(stateBorderAlpha(STATE_BORDER_MIN_PX_PER_DEG)).isEqualTo(0f)
}

@Test
fun stateBorders_fullyDrawnOnceDenseEnough() {
assertThat(stateBorderAlpha(STATE_BORDER_FULL_PX_PER_DEG)).isEqualTo(1f)
assertThat(stateBorderAlpha(40f)).isEqualTo(1f)
}

@Test
fun stateBorders_rampLinearlyBetweenTheThresholds() {
val mid = (STATE_BORDER_MIN_PX_PER_DEG + STATE_BORDER_FULL_PX_PER_DEG) / 2f
assertThat(stateBorderAlpha(mid)).isWithin(1e-4f).of(0.5f)
// Monotonic across the ramp — a pinch never dims the layer.
var previous = 0f
var pxPerDeg = STATE_BORDER_MIN_PX_PER_DEG
while (pxPerDeg <= STATE_BORDER_FULL_PX_PER_DEG) {
val alpha = stateBorderAlpha(pxPerDeg)
assertThat(alpha).isAtLeast(previous)
previous = alpha
pxPerDeg += 0.5f
}
}

@Test
fun stateBorders_drawnOnTheStandardMapCardAtDefaultZoom() {
// The map card measures ~996x1140 px on a 1080p phone. At zoom 1 (whole world, no grid
// set) and at the auto-framed zoom a US operator with a grid actually gets (~2.9 — a hair
// under the old `scale >= 3` gate, which is why the lines never showed), the layer is on.
val world = EquirectViewport(canvasW = 996f, canvasH = 1140f, userScale = 1f)
assertThat(stateBorderAlpha(world.pxPerLonDegree)).isEqualTo(1f)
val framed = EquirectViewport(canvasW = 996f, canvasH = 1140f, userScale = 2.9f)
assertThat(stateBorderAlpha(framed.pxPerLonDegree)).isEqualTo(1f)
}

@Test
fun stateBorders_stayHiddenOnTheAzimuthalWorldDisc() {
// The whole globe inside a ~1000 px disc is ~2.8 px per degree: real mush, still hidden.
assertThat(stateBorderAlpha(azimuthalPxPerLonDegree(discRadiusPx = 500f, scale = 1f)))
.isEqualTo(0f)
// Pinched in, they fade up.
assertThat(stateBorderAlpha(azimuthalPxPerLonDegree(discRadiusPx = 500f, scale = 3f)))
.isEqualTo(1f)
}

// ---- gray-line countdown formatting --------------------------------------
Expand Down
Loading