diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 7e80105..db33102 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -53,6 +53,13 @@ public sealed record Settings /// How long a spot stays on the bandmap. N1MM's own default is 60. public int SpotTimeoutMinutes { get; init; } = 60; + /// The width of the slice the bandmap shows, in hertz. Zero is the whole band. + public int BandmapSpanHertz { get; init; } = 40_000; + + /// The bandmap keeps the receiver in the middle instead of moving only when + /// the receiver nears an edge. + public bool BandmapCentred { get; init; } + /// Moves each incoming CW spot by a few tens of hertz, so the operator has /// to find the station by ear. N1MM calls it randomising. public bool RandomizeSpots { get; init; } diff --git a/src/Nonemm.App/Windows/BandmapView.cs b/src/Nonemm.App/Windows/BandmapView.cs index 13fdeb2..44fb23c 100644 --- a/src/Nonemm.App/Windows/BandmapView.cs +++ b/src/Nonemm.App/Windows/BandmapView.cs @@ -11,6 +11,8 @@ namespace Nonemm.App.Windows; /// Draws the band: a frequency scale down the left, the receiver and /// transmitter as bars on it, and the stations as callsigns on the right with a /// leader line back to where each one really is. +public sealed record BandmapZoom(bool In, Frequency At); + public sealed class BandmapView : Control { private const double ScaleX = 52; @@ -64,6 +66,9 @@ public sealed class BandmapView : Control /// Clicking where no station is asks for the radio, not a callsign. public event EventHandler? FrequencyPicked; + /// The wheel was turned: forward zooms in on the frequency under the pointer. + public event EventHandler? Zoomed; + public override void Render(DrawingContext context) { scale = new BandmapScale(Low, High, Math.Max(1, Bounds.Height - (2 * Inset))); @@ -152,6 +157,16 @@ public sealed class BandmapView : Control context.DrawText(Text(label.Spot.Call.Text, colour), new Point(LabelX, label.LabelY)); } + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + if (e.Delta.Y != 0) + { + Zoomed?.Invoke(this, new BandmapZoom(e.Delta.Y > 0, scale.At(e.GetPosition(this).Y - Inset))); + e.Handled = true; + } + } + protected override void OnPointerPressed(PointerPressedEventArgs e) { base.OnPointerPressed(e); diff --git a/src/Nonemm.App/Windows/BandmapWindow.axaml b/src/Nonemm.App/Windows/BandmapWindow.axaml index e69e2a7..c458438 100644 --- a/src/Nonemm.App/Windows/BandmapWindow.axaml +++ b/src/Nonemm.App/Windows/BandmapWindow.axaml @@ -7,6 +7,7 @@ + diff --git a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs index 4436984..f31856c 100644 --- a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs +++ b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs @@ -15,6 +15,7 @@ public sealed partial class BandmapWindow : RefreshableWindow { private static readonly (string Label, Frequency Span)[] Spans = [ + ("5 kHz", Frequency.FromKilohertz(5)), ("10 kHz", Frequency.FromKilohertz(10)), ("20 kHz", Frequency.FromKilohertz(20)), ("40 kHz", Frequency.FromKilohertz(40)), @@ -25,14 +26,23 @@ public sealed partial class BandmapWindow : RefreshableWindow private readonly AppSession session; private readonly Action tune; + /// Where the slice was last drawn from, so it stays put until the receiver + /// nears an edge. + private Frequency? sliceLow; + public BandmapWindow(AppSession session, Action tune) { this.session = session; this.tune = tune; InitializeComponent(); SpanBox.ItemsSource = Spans.Select(s => s.Label).ToList(); - SpanBox.SelectedIndex = 2; - SpanBox.SelectionChanged += (_, _) => Refresh(); + SpanBox.SelectedIndex = Math.Max( + 0, + Array.FindIndex(Spans, s => s.Span.Hertz == session.Settings.BandmapSpanHertz)); + CentreBox.IsChecked = session.Settings.BandmapCentred; + SpanBox.SelectionChanged += (_, _) => SaveView(); + CentreBox.IsCheckedChanged += (_, _) => SaveView(); + View.Zoomed += (_, zoom) => Zoom(zoom); View.ColourOf = Colour; View.SpotPicked += (_, spot) => tune(spot.Frequency, spot.Call.Text); View.FrequencyPicked += (_, frequency) => tune(frequency, ""); @@ -40,6 +50,16 @@ public sealed partial class BandmapWindow : RefreshableWindow Refresh(); } + private void SaveView() + { + session.Save(session.Settings with + { + BandmapSpanHertz = (int)Spans[Math.Max(0, SpanBox.SelectedIndex)].Span.Hertz, + BandmapCentred = CentreBox.IsChecked == true, + }); + Refresh(); + } + public override void Refresh() { session.Bandmap.DropOlderThan(DateTime.UtcNow); @@ -104,21 +124,34 @@ public sealed partial class BandmapWindow : RefreshableWindow return markers; } - /// The slice to show, centred on the receiver. Contest operating goes - /// outside the band edges, so the slice stretches to wherever the receiver - /// is rather than leaving it off the top of the scale. - private (Frequency Low, Frequency High) Window(Band band, Frequency centre) + private (Frequency Low, Frequency High) Window(Band band, Frequency receiver) { - long lowEdge = Math.Min(band.Low.Hertz, centre.Hertz); - long highEdge = Math.Max(band.High.Hertz, centre.Hertz); - Frequency span = Spans[Math.Max(0, SpanBox.SelectedIndex)].Span; - if (span.Hertz == 0 || span.Hertz >= highEdge - lowEdge) + (Frequency low, Frequency high) = BandmapSlice.Choose( + band, + receiver, + Spans[Math.Max(0, SpanBox.SelectedIndex)].Span.Hertz, + CentreBox.IsChecked == true, + sliceLow); + sliceLow = low; + return (low, high); + } + + /// One wheel notch moves one step along the span list. Unless the slice + /// follows the receiver, the frequency under the pointer stays where it is. + private void Zoom(BandmapZoom zoom) + { + int next = Math.Clamp(SpanBox.SelectedIndex + (zoom.In ? -1 : 1), 0, Spans.Length - 1); + if (next == SpanBox.SelectedIndex) { - return (Frequency.FromHertz(lowEdge), Frequency.FromHertz(highEdge)); + return; } - long half = span.Hertz / 2; - long low = Math.Clamp(centre.Hertz - half, lowEdge, highEdge - span.Hertz); - return (Frequency.FromHertz(low), Frequency.FromHertz(low + span.Hertz)); + long shown = View.High.Hertz - View.Low.Hertz; + long span = Spans[next].Span.Hertz; + if (span > 0) + { + sliceLow = BandmapSlice.Zoom(View.Low, shown, span, zoom.At); + } + SpanBox.SelectedIndex = next; } private static Frequency StepFor(long span) => span switch diff --git a/src/Nonemm.Spotting/BandmapSlice.cs b/src/Nonemm.Spotting/BandmapSlice.cs new file mode 100644 index 0000000..b8f316c --- /dev/null +++ b/src/Nonemm.Spotting/BandmapSlice.cs @@ -0,0 +1,55 @@ +using Nonemm.Core; + +namespace Nonemm.Spotting; + +/// Picks which part of the band the bandmap shows. +public static class BandmapSlice +{ + /// With the slice not following the receiver, it moves once the receiver + /// gets this close to an edge (a tenth of the slice), so it never sits on the very edge. + private const int EdgeDivisor = 10; + + /// `span` is the width wanted in hertz; zero, or a width that is not + /// smaller than the band, shows the whole band. Contest operating goes + /// outside the band edges, so the band is stretched to include the receiver. + /// When `centred` the receiver is in the middle, even past a band edge. + /// Otherwise the slice stays at `previousLow` until the receiver nears an + /// edge, and then moves to centre on it without leaving the band. + public static (Frequency Low, Frequency High) Choose( + Band band, + Frequency receiver, + long span, + bool centred, + Frequency? previousLow) + { + long lowEdge = Math.Min(band.Low.Hertz, receiver.Hertz); + long highEdge = Math.Max(band.High.Hertz, receiver.Hertz); + if (span <= 0 || span >= highEdge - lowEdge) + { + return (Frequency.FromHertz(lowEdge), Frequency.FromHertz(highEdge)); + } + long low; + if (centred) + { + low = receiver.Hertz - (span / 2); + } + else + { + long margin = span / EdgeDivisor; + long kept = Math.Clamp(previousLow?.Hertz ?? long.MinValue, lowEdge, highEdge - span); + bool follows = previousLow is not null + && receiver.Hertz >= kept + margin + && receiver.Hertz <= kept + span - margin; + low = follows + ? kept + : Math.Clamp(receiver.Hertz - (span / 2), lowEdge, highEdge - span); + } + return (Frequency.FromHertz(low), Frequency.FromHertz(low + span)); + } + + /// The bottom of a slice `newSpan` wide that keeps `anchor` at the same + /// height on the screen as it is in the slice `low` to `low + span`, so + /// zooming with the wheel stays on the frequency under the pointer. + public static Frequency Zoom(Frequency low, long span, long newSpan, Frequency anchor) => + Frequency.FromHertz(anchor.Hertz - (long)((anchor.Hertz - low.Hertz) * (double)newSpan / Math.Max(1, span))); +} diff --git a/tests/Nonemm.App.Tests/SettingsTests.cs b/tests/Nonemm.App.Tests/SettingsTests.cs index 05fb677..5fbe082 100644 --- a/tests/Nonemm.App.Tests/SettingsTests.cs +++ b/tests/Nonemm.App.Tests/SettingsTests.cs @@ -93,6 +93,17 @@ public class SettingsTests : IDisposable Assert.Equal("F1 CQ,CQ", settings.CwMessageFile); } + [Fact] + public void TheBandmapViewIsStoredAndReadBack() + { + new Settings { BandmapSpanHertz = 0, BandmapCentred = true }.Save(path); + + Settings read = Settings.Load(path); + + Assert.Equal(0, read.BandmapSpanHertz); + Assert.True(read.BandmapCentred); + } + /// An operator's windows and colours survive a trip through the file, and a /// file written before operators existed reads as a station with none. [Fact] diff --git a/tests/Nonemm.Spotting.Tests/BandmapSliceTests.cs b/tests/Nonemm.Spotting.Tests/BandmapSliceTests.cs new file mode 100644 index 0000000..11c8436 --- /dev/null +++ b/tests/Nonemm.Spotting.Tests/BandmapSliceTests.cs @@ -0,0 +1,64 @@ +using Nonemm.Core; + +namespace Nonemm.Spotting.Tests; + +public class BandmapSliceTests +{ + private static Frequency Khz(double kilohertz) => Frequency.FromHertz((long)(kilohertz * 1000)); + + [Fact] + public void ACentredSliceHasTheReceiverInTheMiddleEvenPastTheBandEdge() + { + (Frequency low, Frequency high) = BandmapSlice.Choose( + Bands.Band20M, Khz(14_348), 20_000, centred: true, previousLow: null); + + Assert.Equal(Khz(14_338), low); + Assert.Equal(Khz(14_358), high); + } + + [Fact] + public void ASliceThatDoesNotFollowKeepsItsPlaceWhileTheReceiverIsInside() + { + (Frequency low, _) = BandmapSlice.Choose( + Bands.Band20M, Khz(14_025), 20_000, centred: false, previousLow: Khz(14_010)); + + Assert.Equal(Khz(14_010), low); + } + + [Fact] + public void ASliceThatDoesNotFollowMovesWhenTheReceiverLeaves() + { + (Frequency low, _) = BandmapSlice.Choose( + Bands.Band20M, Khz(14_100), 20_000, centred: false, previousLow: Khz(14_010)); + + Assert.Equal(Khz(14_090), low); + } + + [Fact] + public void AMovedSliceStaysInsideTheBand() + { + (Frequency low, _) = BandmapSlice.Choose( + Bands.Band20M, Bands.Band20M.Low, 20_000, centred: false, previousLow: null); + + Assert.Equal(Bands.Band20M.Low, low); + } + + [Fact] + public void ASpanOfZeroShowsTheWholeBand() + { + (Frequency low, Frequency high) = BandmapSlice.Choose( + Bands.Band20M, Khz(14_025), 0, centred: true, previousLow: null); + + Assert.Equal(Bands.Band20M.Low, low); + Assert.Equal(Bands.Band20M.High, high); + } + + [Fact] + public void ZoomingKeepsTheFrequencyUnderThePointerAtTheSameHeight() + { + // the pointer is a quarter of the way down a 40 kHz slice + Frequency low = BandmapSlice.Zoom(Khz(14_000), 40_000, 20_000, Khz(14_010)); + + Assert.Equal(Khz(14_005), low); + } +}