diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index ee3b3da94..282ae7b0a 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -128,6 +128,7 @@ import com.k1af.ft8af.rigs.YaesuDX10Rig; import com.k1af.ft8af.spectrum.SpectrumListener; import com.k1af.ft8af.timer.ClockSelfSync; +import com.k1af.ft8af.timer.DecodeDtDisplay; import com.k1af.ft8af.timer.OnUtcTimer; import com.k1af.ft8af.timer.UtcTimer; import com.k1af.ft8af.ui.ToastMessage; @@ -185,6 +186,12 @@ private void fileLog(String msg) { // TimeSyncSettings can reset it when the operator flips the toggles. public final ClockSelfSync clockSelfSync = new ClockSelfSync(); + // Robust, smoothed source for the DT figure the UI shows (clock-sync pill + Time Sync + // readout). Always on — unlike clockSelfSync it never touches the clock, it only decides + // what number is honest to display. See DecodeDtDisplay for why the raw per-slot mean + // this replaced made a perfectly-synced rig look 2 s out. + public final DecodeDtDisplay decodeDtDisplay = new DecodeDtDisplay(); + //public CallsignDatabase callsignDatabase = null;//callsign information database public DatabaseOpr databaseOpr;//configuration info and related data database @@ -705,6 +712,14 @@ public void afterDecode(long utc, float time_sec, int sequential // already ignore own-callsign messages. OwnTxEchoFilter filtered = OwnTxEchoFilter.filter(decoded); ArrayList messages = filtered.kept; + // This pass's per-decode DTs (seconds). Feeds both the self-syncing clock + // (fast pass only, below) and the displayed DT (every pass), so the number + // on screen and the correction the estimator makes are measured from + // exactly the same samples. + final float[] dtSamples = new float[messages.size()]; + for (int i = 0; i < messages.size(); i++) { + dtSamples[i] = messages.get(i).time_sec; + } // Full duplex (satellite operating): our own downlink coming back // IS the measurement, so put the echoes the filter just dropped // back on screen. Strictly display — `display` feeds only the @@ -734,10 +749,6 @@ public void afterDecode(long utc, float time_sec, int sequential if (ClockSelfSync.mayRun(GeneralVariables.autoSyncClockFromDecodes, GeneralVariables.disciplineClockFromGPS, GeneralVariables.disciplineClockFromNtp)) { - float[] dtSamples = new float[messages.size()]; - for (int i = 0; i < messages.size(); i++) { - dtSamples[i] = messages.get(i).time_sec; - } // onSlot is atomic: slot dedup/ordering + streak update under // one lock, so concurrent adjacent-slot deliveries can't // interleave (out-of-order slots are rejected inside). @@ -804,12 +815,29 @@ public void afterDecode(long utc, float time_sec, int sequential appendToMessageList(display); publishFt8MessageList();//post an immutable snapshot so the UI recomposes immediately - // Slot-wide mean DT with own-TX echoes already excluded (see - // OwnTxEchoFilter.meanTimeOffsetSec). NaN can't happen here — messages - // is non-empty, so at least one decode survived — but never post it: - // the pill would render it as "unknown". - if (!Float.isNaN(time_sec)) { - mutableTimerOffset.postValue(time_sec);//this cycle's time offset + // Displayed DT. NOT the slot's raw mean (the `time_sec` argument, kept for + // the diagnostic below): a mean is at the mercy of one late station's DT, and + // it was re-posted on every decode pass, so the pill flashed "2.5 s off" on a + // rig that was working stations perfectly. DecodeDtDisplay applies the same + // robust measurement the self-syncing clock uses — MAD-rejected median per + // slot, accumulated across that slot's passes, then the median of the last + // few slots — and returns null when the reading hasn't meaningfully moved, + // which is what makes the indicator sit still when the clock is right. + Float shownDt = decodeDtDisplay.onDecodes(utc, dtSamples, UtcTimer.delay); + if (shownDt != null && !Float.isNaN(shownDt)) { + mutableTimerOffset.postValue(shownDt); + } + if (!isDeep) { + // Both numbers, once per slot: a debug.log where `raw` swings while + // `shown` holds is the smoothing doing its job, not a drifting clock. + fileLog(String.format(java.util.Locale.US, + "DT: raw=%+.2f shown=%s n=%d", + time_sec, + decodeDtDisplay.lastPublished() == null + ? "--" + : String.format(java.util.Locale.US, "%+.2f", + decodeDtDisplay.lastPublished()), + dtSamples.length)); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/timer/DecodeDtDisplay.java b/ft8af/app/src/main/java/com/k1af/ft8af/timer/DecodeDtDisplay.java new file mode 100644 index 000000000..a1118f8b5 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/timer/DecodeDtDisplay.java @@ -0,0 +1,171 @@ +package com.k1af.ft8af.timer; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Stabilises the decode-DT figure the operating screen shows (the clock-sync pill on the slot + * timer bar, and the "measured DT" readout in Settings → Time Sync). + * + *

Why this exists. The pill used to render the raw arithmetic mean of every + * decode in the slot, re-posted on every decode pass. Three things made that read as an unstable + * app on a rig that was in fact perfectly in time: + * + *

+ * + *

The estimator that actually corrects the clock ({@link ClockSelfSync}) has always + * been robust — median, MAD outlier rejection, multi-slot confirmation — so the display and the + * correction disagreed: the pill flashed "2.5 s off" while auto-sync, looking at the same decodes, + * correctly concluded there was nothing to do. This class puts the display on the same footing: + * + *

+ * + *

A deliberate clock change resets the window. When {@link UtcTimer#delay} moves — the + * operator applied a correction, or GPS/NTP/self-sync did — every stored sample describes the old + * clock. Holding them would average the correction away and make the pill lie for half a minute, + * so the history is dropped and the reading re-acquires from the next slot. + * + *

Pure Java, no Android imports, so it is unit-testable without Robolectric. The entry point is + * synchronized: decode passes for adjacent slots can deliver concurrently. + */ +public class DecodeDtDisplay { + + /** + * How many slots the displayed value is the median of. Three is the smallest window that + * outvotes a single bad slot while still following a real change within ~2 slots (30 s of FT8). + */ + public static final int WINDOW_SLOTS = 3; + + /** + * Minimum change (seconds) before a new reading is published. The pill renders one decimal, so + * anything under half a displayed digit is invisible movement that only costs a recomposition. + */ + public static final float PUBLISH_QUANTUM_SEC = 0.05f; + + /** Cap on the DTs retained for one slot, so a pathological slot can't grow without bound. */ + static final int MAX_SLOT_SAMPLES = 64; + + // The slot currently accumulating, and every DT seen for it across passes. + private long currentUtc = Long.MIN_VALUE; + private float[] currentSamples = new float[0]; + + // Robust medians of the most recently finished slots, oldest first, at most WINDOW_SLOTS - 1 + // (the in-progress slot supplies the newest value). + private final List finishedMedians = new ArrayList<>(); + + // Last value handed to the UI, for the publish deadband. Null = nothing published yet. + private Float published = null; + + // The clock offset the stored samples were measured against. + private Integer sampledAtDelayMs = null; + + /** + * Feed one decode pass's per-decode DTs (seconds, own-TX echoes already filtered out) and get + * the value the UI should now show, or {@code null} to keep showing what it has. + * + * @param utc the slot the decodes belong to; stragglers for an older slot are ignored + * @param dtSec this pass's DTs; may be empty + * @param clockDelayMs the live {@link UtcTimer#delay} — a change resets the window + */ + public synchronized Float onDecodes(long utc, float[] dtSec, int clockDelayMs) { + if (sampledAtDelayMs == null || sampledAtDelayMs != clockDelayMs) { + // The clock moved under us: everything measured before it describes a different clock. + resetHistory(); + sampledAtDelayMs = clockDelayMs; + } + if (utc < currentUtc) { + // A late pass for an already-superseded slot — stale evidence, ignore it. + return null; + } + if (utc > currentUtc) { + finishCurrentSlot(); + currentUtc = utc; + currentSamples = new float[0]; + } + appendSamples(dtSec); + + float[] survivors = ClockSelfSync.rejectOutliers(currentSamples); + if (survivors.length == 0) { + return null; + } + float slotMedian = ClockSelfSync.medianOf(survivors); + + float[] window = new float[finishedMedians.size() + 1]; + for (int i = 0; i < finishedMedians.size(); i++) { + window[i] = finishedMedians.get(i); + } + window[window.length - 1] = slotMedian; + float display = ClockSelfSync.medianOf(window); + + if (published != null && Math.abs(display - published) < PUBLISH_QUANTUM_SEC) { + return null; + } + published = display; + return display; + } + + /** The value last handed to the UI, or null if nothing has been published yet. */ + public synchronized Float lastPublished() { + return published; + } + + /** Drops all history — the samples, the window, and the published value. */ + public synchronized void reset() { + resetHistory(); + sampledAtDelayMs = null; + } + + private void resetHistory() { + finishedMedians.clear(); + currentSamples = new float[0]; + currentUtc = Long.MIN_VALUE; + published = null; + } + + /** Retire the in-progress slot into the window (dropping the oldest once it is full). */ + private void finishCurrentSlot() { + float[] survivors = ClockSelfSync.rejectOutliers(currentSamples); + if (survivors.length == 0) { + return; + } + finishedMedians.add(ClockSelfSync.medianOf(survivors)); + while (finishedMedians.size() > WINDOW_SLOTS - 1) { + finishedMedians.remove(0); + } + } + + private void appendSamples(float[] dtSec) { + if (dtSec == null || dtSec.length == 0) { + return; + } + int room = MAX_SLOT_SAMPLES - currentSamples.length; + if (room <= 0) { + return; + } + int take = Math.min(room, dtSec.length); + float[] merged = Arrays.copyOf(currentSamples, currentSamples.length + take); + System.arraycopy(dtSec, 0, merged, currentSamples.length, take); + currentSamples = merged; + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index 877b736d1..cb0f8f03d 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -319,8 +319,9 @@ fun FT8AFApp(mainViewModel: MainViewModel) { val operatingMode by mainViewModel.mutableOperatingMode.observeAsState(GeneralVariables.operatingMode) val modeName = ModeProfile.fromId(operatingMode).displayName - // Mean decode DT (seconds) for the slot-timer bar's live clock-sync pill; null until - // the first decode cycle reports one, so the bar renders unchanged before then. + // Decode DT (seconds) for the slot-timer bar's live clock-sync pill — the smoothed, + // outlier-rejected reading from DecodeDtDisplay, not a raw per-slot mean; null until the + // first decode cycle reports one, so the bar renders unchanged before then. val avgDtSec by mainViewModel.mutableTimerOffset.observeAsState() // Observe SWR lockout state @@ -602,7 +603,14 @@ fun FT8AFApp(mainViewModel: MainViewModel) { showCatChip = showCatChip, catState = catState, onReconnectCat = onReconnectCatAction, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp), + // Bottom padding separates the status line from the sheet's rounded top edge + // below it — butted together, the rounding read as if the line were clipped. + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 10.dp, + ), ) Spacer( modifier = Modifier diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt index 3800f24e1..52ee333c0 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt @@ -29,15 +29,19 @@ import java.util.Locale import kotlin.math.abs /** - * Clock-sync health derived from the mean decode DT (time offset, in seconds). + * Clock-sync health derived from the smoothed decode DT (time offset, in seconds) — + * `DecodeDtDisplay`'s reading: an outlier-rejected median per slot, smoothed over the last few + * slots. It is deliberately not the slot's raw mean; see that class for why a mean made a rig + * that was working stations perfectly report itself seconds out of sync. * * FT8 tolerates only ~2 s of clock error before decoding collapses — both ways: a * mis-set clock stops you decoding others *and* pushes your transmissions to the edge * of everyone else's receive window so they can't decode you either. Yet the app never * surfaced this on the operating screen; the operator had to open Settings → Time Sync - * to notice. The mean DT of the stations you decode is a good proxy for your own clock + * to notice. The consensus DT of the stations you decode is a good proxy for your own clock * offset: most stations are themselves synced, so a consistent bias across all of them - * is almost certainly yours. + * is almost certainly yours — while any single station's DT is that station's own clock, which + * is why the displayed value is a median over several slots rather than one slot's average. */ internal enum class ClockSyncLevel { GOOD, FAIR, POOR, UNKNOWN } @@ -48,7 +52,7 @@ internal const val CLOCK_SYNC_GOOD_SEC = 0.3f internal const val CLOCK_SYNC_FAIR_SEC = 1.0f /** - * Classify the mean decode DT into a sync-health level. A `null` value means no decode + * Classify the smoothed decode DT into a sync-health level. A `null` value means no decode * cycle has reported yet this session; a non-finite value is treated the same way so a bad * reading can never mis-color the pill. */ diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt index 3117e27fd..450f6d975 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt @@ -1,10 +1,6 @@ package radio.ks3ckc.ft8af.ui.components import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -73,11 +69,37 @@ internal fun drawerTargetExpanded( /** The vertical drag distance on the handle that commits to toggling the drawer. */ internal val DrawerDragThreshold = 48.dp +/** Height of the grab handle strip (grabber bar + the collapsed "More controls" hint). */ +internal val DrawerHandleHeight = 36.dp + +/** Gap between the handle strip and the first control row. */ +internal val DrawerContentTopGap = 10.dp + +/** + * Padding below the last visible control row. In the collapsed peek this is the breathing room + * between the Call CQ / Hunt row and the tab bar the sheet sits on, so it must be comfortably + * bigger than nothing and comfortably smaller than [DrawerRowSpacing] (see below). + */ +internal val DrawerContentBottomPad = 14.dp + /** - * The collapsed height of the drawer — the handle (with its "More controls" hint) plus the primary - * Call CQ + Hunt row, stopping before the next row so nothing peeks through when collapsed. + * Vertical gap between control rows. Deliberately larger than [DrawerContentBottomPad]: the + * collapsed sheet is clipped [DrawerContentBottomPad] past the primary row's bottom edge, so the + * next row only stays fully hidden while the gap exceeds that padding. */ -internal val OperateDrawerPeekHeight = 120.dp +internal val DrawerRowSpacing = 18.dp + +/** + * The collapsed height of the drawer — the handle (with its "More controls" hint), the primary + * Call CQ + Hunt row, and equal-feeling padding above and below it, stopping short of the next row + * so nothing peeks through when collapsed. + * + * Derived from the parts rather than hard-coded (it was a flat 120.dp, which left the 72.dp button + * row butted against the handle with 6.dp under it — the row read as jammed onto the tab bar) so + * the peek can never clip the primary row or reveal the row below it. + */ +internal val OperateDrawerPeekHeight = + DrawerHandleHeight + DrawerContentTopGap + OperatePrimaryRowHeight + DrawerContentBottomPad /** * The operate-controls drawer (design 3a/3b) as a *single* bottom sheet that grows in place. @@ -199,8 +221,13 @@ fun OperateControlsDrawer( Column( modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .padding( + start = 16.dp, + end = 16.dp, + top = DrawerContentTopGap, + bottom = DrawerContentBottomPad, + ), + verticalArrangement = Arrangement.spacedBy(DrawerRowSpacing), ) { // ---- Primary row (visible in the peek): Call CQ / Stop + Hunt ---- CqHuntRow( @@ -246,9 +273,13 @@ fun OperateControlsDrawer( /** * The drawer's grab handle: a centered grabber bar plus — while collapsed — an up-chevron and - * "More controls" caption that gently bob to advertise that the sheet pulls up. A tap toggles the - * drawer; a vertical drag is fed live to [onDrag] (dy, negative = up) and settled in [onDragEnd] - * with the total drag distance. + * "More controls" caption advertising that the sheet pulls up. A tap toggles the drawer; a + * vertical drag is fed live to [onDrag] (dy, negative = up) and settled in [onDragEnd] with the + * total drag distance. + * + * The hint used to bob up and down on an infinite animation. It sat directly above the primary + * action row, where a caption drifting against fixed buttons read as uneven spacing rather than as + * an affordance, so it is static now — the chevron carries the message. */ @Composable private fun DrawerHandle( @@ -263,19 +294,10 @@ private fun DrawerHandle( val collapseLabel = stringResource(R.string.controls_drawer_collapse) val label = if (expanded) collapseLabel else expandLabel - // Gentle up-and-down bob on the hint, only while collapsed, to draw the eye to the affordance. - val bobTransition = rememberInfiniteTransition(label = "drawer-hint-bob") - val bob by bobTransition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), - label = "drawer-hint-bob-offset", - ) - Column( modifier = modifier .fillMaxWidth() - .height(42.dp) + .height(DrawerHandleHeight) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -293,7 +315,7 @@ private fun DrawerHandle( ) }, horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(3.dp, Alignment.CenterVertically), + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), ) { Box( modifier = Modifier @@ -307,7 +329,6 @@ private fun DrawerHandle( FT8AFIcons.ChevronDown(size = 14.dp, color = TextFaint, strokeWidth = 2.2f) } else { Row( - modifier = Modifier.offset(y = (bob * -3f).dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp), ) { diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt index 5d67e1b1a..c88862b6a 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt @@ -63,6 +63,13 @@ internal data class TxStripActionState( val cqIsStop: Boolean, ) +/** + * Height of the primary action row — the Call CQ button and the Hunt tile beside it. Shared so + * the collapsing drawer can derive its peek height from the real button height instead of + * repeating the number (see OperateDrawerPeekHeight). + */ +internal val OperatePrimaryRowHeight = 72.dp + /** * Clamp a volume value after a +/- step to the 0–100 range. * Extracted so it can be unit-tested without Compose. @@ -528,7 +535,7 @@ internal fun CallCqButton( Row( modifier = modifier - .height(72.dp) + .height(OperatePrimaryRowHeight) .clip(RoundedCornerShape(14.dp)) .background(background) .combinedClickable( @@ -629,7 +636,7 @@ internal fun HuntTile( val contentAlpha = if (huntDisabled) 0.4f else 1f Column( modifier = modifier - .height(72.dp) + .height(OperatePrimaryRowHeight) .clip(RoundedCornerShape(14.dp)) .background(if (huntDisabled) BgSurface3.copy(alpha = 0.4f) else BgSurface3) .border(1.dp, Border, RoundedCornerShape(14.dp)) 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/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt index 301744a5e..9698ee7d7 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt @@ -40,7 +40,7 @@ internal fun stepCorrectionMs(current: Int, deltaMs: Int): Int = clampCorrectionMs(current + deltaMs) /** - * Suggest a new correction from the average decode DT of the stations we're + * Suggest a new correction from the smoothed decode DT of the stations we're * hearing — the only time reference available offline. * * `avgDtSec` is WSJT-style DT (seconds): how far into our RX window the decoded diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt index 70db3e1dc..3a177928c 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt @@ -96,8 +96,8 @@ fun TimeSyncSettings( GeneralVariables.mutableNtpClockSyncFailed.value ) - // Latest cycle's average decode DT (seconds), posted in MainViewModel.afterDecode. - // Null until the first decode this session. + // Smoothed decode DT (seconds) — DecodeDtDisplay's reading, posted in + // MainViewModel.afterDecode. Null until the first decode this session. val avgDtSec by mainViewModel.mutableTimerOffset.observeAsState() // Last self-sync correction (timestamp), so the Suggestion card can show that diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/timer/DecodeDtDisplayTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/timer/DecodeDtDisplayTest.java new file mode 100644 index 000000000..56ef8c79d --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/timer/DecodeDtDisplayTest.java @@ -0,0 +1,153 @@ +package com.k1af.ft8af.timer; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Unit tests for {@link DecodeDtDisplay} — the robust, smoothed source for the DT figure shown on + * the operating screen. Pure Java, no Robolectric needed. + * + *

The behaviour under test is the fix for "my DT indicator says 2.5 s off while the rig works + * stations perfectly": one station's DT, or one marginal deep-pass decode, must not be allowed to + * move the displayed number, while a real, sustained clock error still must. + */ +public class DecodeDtDisplayTest { + + private static final int DELAY = 0; + + /** Slot boundaries only have to be increasing; real ones are UTC ms. */ + private static long slot(int n) { + return 1_700_000_000_000L + n * 15_000L; + } + + @Test + public void firstSlotPublishesImmediately() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + Float shown = dt.onDecodes(slot(1), new float[]{0.4f, 0.5f, 0.6f}, DELAY); + assertThat(shown).isNotNull(); + assertThat(shown).isWithin(1e-4f).of(0.5f); + } + + @Test + public void oneLateStationCannotDragTheReading() { + // The exact shape of the complaint: three in-time decodes and one station 7 s late. + // The old arithmetic mean of these is +1.85 s (a red "clock off" verdict). + DecodeDtDisplay dt = new DecodeDtDisplay(); + Float shown = dt.onDecodes(slot(1), new float[]{0.1f, 0.0f, 0.2f, 7.0f}, DELAY); + assertThat(shown).isNotNull(); + assertThat(Math.abs(shown)).isLessThan(0.3f); + } + + @Test + public void deepPassRefinesTheSlotInsteadOfReplacingIt() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + Float fast = dt.onDecodes(slot(1), new float[]{0.1f, 0.1f, 0.2f}, DELAY); + assertThat(fast).isNotNull(); + // A deep pass for the SAME slot digs out one marginal decode with a wild DT. It joins the + // slot's samples (where it is rejected) rather than becoming the new reading. + Float deep = dt.onDecodes(slot(1), new float[]{2.4f}, DELAY); + assertThat(deep).isNull(); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(fast); + } + + @Test + public void oneFreakSlotIsOutvotedByTheWindow() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(1), new float[]{0.1f}, DELAY); + dt.onDecodes(slot(2), new float[]{0.1f}, DELAY); + // A whole slot whose only decode is a late station: the median of the last three slots + // still reads the healthy clock. + Float shown = dt.onDecodes(slot(3), new float[]{3.0f}, DELAY); + assertThat(shown).isNull(); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(0.1f); + } + + @Test + public void sustainedErrorIsStillFollowed() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(1), new float[]{0.0f, 0.0f}, DELAY); + dt.onDecodes(slot(2), new float[]{0.0f, 0.0f}, DELAY); + // The clock genuinely drifts: every station now lands a second late. + dt.onDecodes(slot(3), new float[]{1.0f, 1.0f}, DELAY); + dt.onDecodes(slot(4), new float[]{1.0f, 1.0f}, DELAY); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(1.0f); + } + + @Test + public void tinyChangesAreHeldSoTheLastDigitStopsTwitching() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + assertThat(dt.onDecodes(slot(1), new float[]{0.10f}, DELAY)).isNotNull(); + assertThat(dt.onDecodes(slot(2), new float[]{0.12f}, DELAY)).isNull(); + assertThat(dt.onDecodes(slot(3), new float[]{0.11f}, DELAY)).isNull(); + } + + @Test + public void aClockCorrectionResetsTheWindow() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(1), new float[]{1.2f}, DELAY); + dt.onDecodes(slot(2), new float[]{1.2f}, DELAY); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(1.2f); + // Auto-sync (or the operator) trims the clock by 1.2 s: the stored samples describe the + // old clock, so the next slot re-acquires on its own instead of being averaged with them. + Float shown = dt.onDecodes(slot(3), new float[]{0.0f}, DELAY - 1200); + assertThat(shown).isNotNull(); + assertThat(shown).isWithin(1e-4f).of(0.0f); + } + + @Test + public void stragglerForAnOlderSlotIsIgnored() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(2), new float[]{0.1f}, DELAY); + // A late pass for the previous slot arriving after its successor: stale evidence. + assertThat(dt.onDecodes(slot(1), new float[]{4.0f}, DELAY)).isNull(); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(0.1f); + } + + @Test + public void emptyPassPublishesNothing() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + assertThat(dt.onDecodes(slot(1), new float[0], DELAY)).isNull(); + assertThat(dt.onDecodes(slot(2), null, DELAY)).isNull(); + assertThat(dt.lastPublished()).isNull(); + } + + @Test + public void windowOnlyRemembersTheMostRecentSlots() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + // Three slots of a real +2 s error, then the clock is fixed and stays fixed. Once the + // window has rolled past the bad slots the reading must be the new truth, not a blend. + for (int i = 1; i <= 3; i++) { + dt.onDecodes(slot(i), new float[]{2.0f}, DELAY); + } + assertThat(dt.lastPublished()).isWithin(1e-4f).of(2.0f); + for (int i = 4; i <= 3 + DecodeDtDisplay.WINDOW_SLOTS; i++) { + dt.onDecodes(slot(i), new float[]{0.0f}, DELAY); + } + assertThat(dt.lastPublished()).isWithin(1e-4f).of(0.0f); + } + + @Test + public void slotSamplesAreCappedAcrossPasses() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + float[] batch = new float[DecodeDtDisplay.MAX_SLOT_SAMPLES]; + for (int i = 0; i < batch.length; i++) { + batch[i] = 0.2f; + } + assertThat(dt.onDecodes(slot(1), batch, DELAY)).isNotNull(); + // Past the cap, further passes for the same slot are dropped rather than growing forever; + // the reading stands on what it already has. + assertThat(dt.onDecodes(slot(1), new float[]{9.0f, 9.0f, 9.0f}, DELAY)).isNull(); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(0.2f); + } + + @Test + public void resetClearsEverything() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(1), new float[]{0.7f}, DELAY); + dt.reset(); + assertThat(dt.lastPublished()).isNull(); + Float shown = dt.onDecodes(slot(2), new float[]{-0.4f}, DELAY); + assertThat(shown).isWithin(1e-4f).of(-0.4f); + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt index 39a036eb8..91e1fafea 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt @@ -1,12 +1,14 @@ package radio.ks3ckc.ft8af.ui.components +import androidx.compose.ui.unit.dp import com.google.common.truth.Truth.assertThat import org.junit.Test /** * Unit tests for [drawerTargetExpanded] — the pure gesture rule deciding whether the - * operate-controls drawer (design 3a/3b) settles open or collapsed after a drag on its handle. - * No Android/Compose runtime needed. Sign convention: negative dy = upward drag. + * operate-controls drawer (design 3a/3b) settles open or collapsed after a drag on its handle — + * plus the collapsed-peek geometry invariants. No Android/Compose runtime needed. Sign convention: + * negative dy = upward drag. */ class OperateControlsDrawerTest { @@ -67,4 +69,36 @@ class OperateControlsDrawerTest { assertThat(drawerTargetExpanded(currentlyExpanded = true, dragDy = 0f, thresholdPx = threshold)) .isTrue() } + + // ---- Collapsed-peek geometry (see OperateDrawerPeekHeight) ---- + + @Test + fun `peek height is the handle, the primary row and its padding`() { + assertThat(OperateDrawerPeekHeight).isEqualTo( + DrawerHandleHeight + DrawerContentTopGap + OperatePrimaryRowHeight + + DrawerContentBottomPad + ) + } + + @Test + fun `peek height clears the whole primary row`() { + // Anything less and the collapsed sheet clips the Call CQ / Hunt buttons. + assertThat(OperateDrawerPeekHeight) + .isGreaterThan(DrawerHandleHeight + DrawerContentTopGap + OperatePrimaryRowHeight) + } + + @Test + fun `row gap exceeds the bottom padding so the next row cannot peek through`() { + // The sheet is clipped DrawerContentBottomPad past the primary row's bottom edge; the + // following row starts DrawerRowSpacing past it, so the gap has to be the larger of the two. + assertThat(DrawerRowSpacing).isGreaterThan(DrawerContentBottomPad) + } + + @Test + fun `primary row has breathing room above and below it`() { + // The 120.dp peek this replaced left 0.dp above the buttons and 6.dp below, which is what + // made the collapsed drawer look jammed onto the tab bar. + assertThat(DrawerContentTopGap).isAtLeast(8.dp) + assertThat(DrawerContentBottomPad).isAtLeast(8.dp) + } } 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 --------------------------------------