diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt index b1771ac8d..9bcf2e980 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt @@ -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, + ) } } @@ -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) @@ -1825,14 +1834,52 @@ private fun DrawScope.drawWorldLand(rings: List, 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( diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapViewport.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapViewport.kt index db57c039b..99365fd9c 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapViewport.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapViewport.kt @@ -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 diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapRedesignTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapRedesignTest.kt index cd5e92753..af73e5810 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapRedesignTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapRedesignTest.kt @@ -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 { @@ -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 --------------------------------------