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 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 00:52:06 +00:00
co-authored by Claude Sonnet 5.5
parent 476844a9f0
commit 047a3612cb
7 changed files with 200 additions and 14 deletions
+7
View File
@@ -53,6 +53,13 @@ public sealed record Settings
/// How long a spot stays on the bandmap. N1MM's own default is 60. /// How long a spot stays on the bandmap. N1MM's own default is 60.
public int SpotTimeoutMinutes { get; init; } = 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 /// 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. /// to find the station by ear. N1MM calls it randomising.
public bool RandomizeSpots { get; init; } public bool RandomizeSpots { get; init; }
+15
View File
@@ -11,6 +11,8 @@ namespace Nonemm.App.Windows;
/// Draws the band: a frequency scale down the left, the receiver and /// 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 /// transmitter as bars on it, and the stations as callsigns on the right with a
/// leader line back to where each one really is. /// leader line back to where each one really is.
public sealed record BandmapZoom(bool In, Frequency At);
public sealed class BandmapView : Control public sealed class BandmapView : Control
{ {
private const double ScaleX = 52; 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. /// Clicking where no station is asks for the radio, not a callsign.
public event EventHandler<Frequency>? FrequencyPicked; public event EventHandler<Frequency>? FrequencyPicked;
/// The wheel was turned: forward zooms in on the frequency under the pointer.
public event EventHandler<BandmapZoom>? Zoomed;
public override void Render(DrawingContext context) public override void Render(DrawingContext context)
{ {
scale = new BandmapScale(Low, High, Math.Max(1, Bounds.Height - (2 * Inset))); 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)); 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) protected override void OnPointerPressed(PointerPressedEventArgs e)
{ {
base.OnPointerPressed(e); base.OnPointerPressed(e);
@@ -7,6 +7,7 @@
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="6,6,6,4" Spacing="8"> <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="6,6,6,4" Spacing="8">
<TextBlock Name="BandText" FontSize="12" VerticalAlignment="Center" /> <TextBlock Name="BandText" FontSize="12" VerticalAlignment="Center" />
<ComboBox Name="SpanBox" FontSize="11" Width="96" /> <ComboBox Name="SpanBox" FontSize="11" Width="96" />
<CheckBox Name="CentreBox" Content="Centre on VFO" FontSize="11" />
</StackPanel> </StackPanel>
<TextBlock DockPanel.Dock="Bottom" Name="StatusText" Margin="6,2,6,6" <TextBlock DockPanel.Dock="Bottom" Name="StatusText" Margin="6,2,6,6"
FontSize="11" Opacity="0.7" /> FontSize="11" Opacity="0.7" />
+48 -15
View File
@@ -15,6 +15,7 @@ public sealed partial class BandmapWindow : RefreshableWindow
{ {
private static readonly (string Label, Frequency Span)[] Spans = private static readonly (string Label, Frequency Span)[] Spans =
[ [
("5 kHz", Frequency.FromKilohertz(5)),
("10 kHz", Frequency.FromKilohertz(10)), ("10 kHz", Frequency.FromKilohertz(10)),
("20 kHz", Frequency.FromKilohertz(20)), ("20 kHz", Frequency.FromKilohertz(20)),
("40 kHz", Frequency.FromKilohertz(40)), ("40 kHz", Frequency.FromKilohertz(40)),
@@ -25,14 +26,23 @@ public sealed partial class BandmapWindow : RefreshableWindow
private readonly AppSession session; private readonly AppSession session;
private readonly Action<Frequency, string> tune; private readonly Action<Frequency, string> 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<Frequency, string> tune) public BandmapWindow(AppSession session, Action<Frequency, string> tune)
{ {
this.session = session; this.session = session;
this.tune = tune; this.tune = tune;
InitializeComponent(); InitializeComponent();
SpanBox.ItemsSource = Spans.Select(s => s.Label).ToList(); SpanBox.ItemsSource = Spans.Select(s => s.Label).ToList();
SpanBox.SelectedIndex = 2; SpanBox.SelectedIndex = Math.Max(
SpanBox.SelectionChanged += (_, _) => Refresh(); 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.ColourOf = Colour;
View.SpotPicked += (_, spot) => tune(spot.Frequency, spot.Call.Text); View.SpotPicked += (_, spot) => tune(spot.Frequency, spot.Call.Text);
View.FrequencyPicked += (_, frequency) => tune(frequency, ""); View.FrequencyPicked += (_, frequency) => tune(frequency, "");
@@ -40,6 +50,16 @@ public sealed partial class BandmapWindow : RefreshableWindow
Refresh(); 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() public override void Refresh()
{ {
session.Bandmap.DropOlderThan(DateTime.UtcNow); session.Bandmap.DropOlderThan(DateTime.UtcNow);
@@ -104,21 +124,34 @@ public sealed partial class BandmapWindow : RefreshableWindow
return markers; return markers;
} }
/// The slice to show, centred on the receiver. Contest operating goes private (Frequency Low, Frequency High) Window(Band band, Frequency receiver)
/// 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)
{ {
long lowEdge = Math.Min(band.Low.Hertz, centre.Hertz); (Frequency low, Frequency high) = BandmapSlice.Choose(
long highEdge = Math.Max(band.High.Hertz, centre.Hertz); band,
Frequency span = Spans[Math.Max(0, SpanBox.SelectedIndex)].Span; receiver,
if (span.Hertz == 0 || span.Hertz >= highEdge - lowEdge) Spans[Math.Max(0, SpanBox.SelectedIndex)].Span.Hertz,
{ CentreBox.IsChecked == true,
return (Frequency.FromHertz(lowEdge), Frequency.FromHertz(highEdge)); sliceLow);
sliceLow = low;
return (low, high);
} }
long half = span.Hertz / 2;
long low = Math.Clamp(centre.Hertz - half, lowEdge, highEdge - span.Hertz); /// One wheel notch moves one step along the span list. Unless the slice
return (Frequency.FromHertz(low), Frequency.FromHertz(low + span.Hertz)); /// 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;
}
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 private static Frequency StepFor(long span) => span switch
+55
View File
@@ -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)));
}
+11
View File
@@ -93,6 +93,17 @@ public class SettingsTests : IDisposable
Assert.Equal("F1 CQ,CQ", settings.CwMessageFile); 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 /// 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. /// file written before operators existed reads as a station with none.
[Fact] [Fact]
@@ -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);
}
}