From 047a3612cb41820c9eb3a5ba7b29c1c5c9bf31b2 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sat, 24 Oct 2026 00:52:06 +0000 Subject: [PATCH] Zoom the bandmap with the mouse wheel and add a Centre on VFO toggle The wheel steps along the span list and keeps the frequency under the pointer in place. With Centre on VFO off, the slice stays put until the receiver nears an edge. The span and the toggle are saved in the settings. Co-Authored-By: Claude Sonnet 5.5 --- src/Nonemm.App/Configuration/Settings.cs | 7 ++ src/Nonemm.App/Windows/BandmapView.cs | 15 +++++ src/Nonemm.App/Windows/BandmapWindow.axaml | 1 + src/Nonemm.App/Windows/BandmapWindow.axaml.cs | 61 ++++++++++++++---- src/Nonemm.Spotting/BandmapSlice.cs | 55 ++++++++++++++++ tests/Nonemm.App.Tests/SettingsTests.cs | 11 ++++ .../BandmapSliceTests.cs | 64 +++++++++++++++++++ 7 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 src/Nonemm.Spotting/BandmapSlice.cs create mode 100644 tests/Nonemm.Spotting.Tests/BandmapSliceTests.cs 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); + } +}