Add a 60 kHz bandmap zoom, a mode-wide zoom and denser scale ticks

The zoom list gains 60 kHz and a "mode" level that shows the CW, digital
or phone part of the band the receiver is in, before "band". For views
from 40 to 200 kHz wide, the scale picks the smallest round step that
keeps three lines of text between ticks. The font size buttons are
removed; Ctrl with the wheel or plus and minus still change the size.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 12:12:35 +00:00
co-authored by Claude Sonnet 5.5
parent a189f63732
commit b32c09aeef
10 changed files with 108 additions and 35 deletions
+2 -1
View File
@@ -53,7 +53,8 @@ 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. /// The width of the slice the bandmap shows, in hertz. Zero is the whole band
/// and minus one is the CW, digital or phone part the receiver is in.
public int BandmapSpanHertz { get; init; } = 40_000; public int BandmapSpanHertz { get; init; } = 40_000;
/// The bandmap keeps the receiver in the middle instead of moving only when /// The bandmap keeps the receiver in the middle instead of moving only when
+5 -1
View File
@@ -78,6 +78,10 @@ public sealed class BandmapView : Control
/// The step between the frequency numbers down the scale. /// The step between the frequency numbers down the scale.
public Frequency TickStep { get; set; } = Frequency.FromKilohertz(5); public Frequency TickStep { get; set; } = Frequency.FromKilohertz(5);
/// Sets the step from the height of the window instead: as many ticks as
/// fit with room for three lines of text between them.
public bool DenseTicks { get; set; }
public event EventHandler<Spot>? SpotPicked; public event EventHandler<Spot>? SpotPicked;
/// Clicking where no station is asks for the radio, not a callsign. /// Clicking where no station is asks for the radio, not a callsign.
@@ -139,7 +143,7 @@ public sealed class BandmapView : Control
{ {
Pen line = new(Opaque(ink, 0.7), 1); Pen line = new(Opaque(ink, 0.7), 1);
context.DrawLine(line, new Point(ScaleX, Inset), new Point(ScaleX, Inset + scale.Height)); context.DrawLine(line, new Point(ScaleX, Inset), new Point(ScaleX, Inset + scale.Height));
foreach (Frequency tick in BandmapLayout.Ticks(scale, TickStep)) foreach (Frequency tick in BandmapLayout.Ticks(scale, DenseTicks ? BandmapLayout.StepFor(scale, 3 * LabelHeight) : TickStep))
{ {
double y = Math.Round(scale.YFor(tick) + Inset) + 0.5; double y = Math.Round(scale.YFor(tick) + Inset) + 0.5;
context.DrawLine(line, new Point(ScaleX - TickLength, y), new Point(ScaleX, y)); context.DrawLine(line, new Point(ScaleX - TickLength, y), new Point(ScaleX, y));
@@ -5,12 +5,6 @@
Title="Bandmap" Width="320" Height="620"> Title="Bandmap" Width="320" Height="620">
<DockPanel> <DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="6,6,6,4" LastChildFill="False"> <DockPanel DockPanel.Dock="Top" Margin="6,6,6,4" LastChildFill="False">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="2">
<Button Name="SmallerButton" Content="&#8722;" MinWidth="24" Padding="6,0" Height="24"
FontSize="12" ToolTip.Tip="Smaller callsigns" />
<Button Name="LargerButton" Content="+" MinWidth="24" Padding="6,0" Height="24"
FontSize="12" ToolTip.Tip="Larger callsigns" />
</StackPanel>
<ComboBox Name="SpanBox" FontSize="11" Width="96" /> <ComboBox Name="SpanBox" FontSize="11" Width="96" />
<CheckBox Name="CentreBox" Content="Centre on VFO" FontSize="11" Margin="8,0,0,0" /> <CheckBox Name="CentreBox" Content="Centre on VFO" FontSize="11" Margin="8,0,0,0" />
</DockPanel> </DockPanel>
+25 -27
View File
@@ -14,16 +14,22 @@ namespace Nonemm.App.Windows;
/// moves the radio. /// moves the radio.
public sealed partial class BandmapWindow : RefreshableWindow public sealed partial class BandmapWindow : RefreshableWindow
{ {
private static readonly (string Label, Frequency Span)[] Spans = /// Stands in the span list for the CW, digital or phone part of the band
/// the receiver is in.
private const long PartOfBand = -1;
private static readonly (string Label, long Hertz)[] Spans =
[ [
("5 kHz", Frequency.FromKilohertz(5)), ("5 kHz", 5_000),
("10 kHz", Frequency.FromKilohertz(10)), ("10 kHz", 10_000),
("20 kHz", Frequency.FromKilohertz(20)), ("20 kHz", 20_000),
("40 kHz", Frequency.FromKilohertz(40)), ("40 kHz", 40_000),
("75 kHz", Frequency.FromKilohertz(75)), ("60 kHz", 60_000),
("100 kHz", Frequency.FromKilohertz(100)), ("75 kHz", 75_000),
("150 kHz", Frequency.FromKilohertz(150)), ("100 kHz", 100_000),
("whole band", Frequency.Zero), ("150 kHz", 150_000),
("mode", PartOfBand),
("band", 0),
]; ];
private readonly AppSession session; private readonly AppSession session;
@@ -41,7 +47,7 @@ public sealed partial class BandmapWindow : RefreshableWindow
SpanBox.ItemsSource = Spans.Select(s => s.Label).ToList(); SpanBox.ItemsSource = Spans.Select(s => s.Label).ToList();
SpanBox.SelectedIndex = Math.Max( SpanBox.SelectedIndex = Math.Max(
0, 0,
Array.FindIndex(Spans, s => s.Span.Hertz == session.Settings.BandmapSpanHertz)); Array.FindIndex(Spans, s => s.Hertz == session.Settings.BandmapSpanHertz));
CentreBox.IsChecked = session.Settings.BandmapCentred; CentreBox.IsChecked = session.Settings.BandmapCentred;
View.TextSize = session.Settings.BandmapTextSize; View.TextSize = session.Settings.BandmapTextSize;
_ = new FontZoom(this, new Settings().BandmapTextSize, () => (int)View.TextSize, size => _ = new FontZoom(this, new Settings().BandmapTextSize, () => (int)View.TextSize, size =>
@@ -49,8 +55,6 @@ public sealed partial class BandmapWindow : RefreshableWindow
View.TextSize = size; View.TextSize = size;
SaveView(); SaveView();
}); });
SmallerButton.Click += (_, _) => ResizeText(-1);
LargerButton.Click += (_, _) => ResizeText(1);
SpanBox.SelectionChanged += (_, _) => SaveView(); SpanBox.SelectionChanged += (_, _) => SaveView();
CentreBox.IsCheckedChanged += (_, _) => SaveView(); CentreBox.IsCheckedChanged += (_, _) => SaveView();
View.Zoomed += (_, zoom) => Zoom(zoom); View.Zoomed += (_, zoom) => Zoom(zoom);
@@ -61,18 +65,12 @@ public sealed partial class BandmapWindow : RefreshableWindow
Refresh(); Refresh();
} }
private void ResizeText(int by)
{
View.TextSize = Math.Clamp(View.TextSize + by, FontZoom.Smallest, FontZoom.Largest);
SaveView();
}
private void SaveView() private void SaveView()
{ {
session.Save(session.Settings with session.Save(session.Settings with
{ {
BandmapTextSize = (int)View.TextSize, BandmapTextSize = (int)View.TextSize,
BandmapSpanHertz = (int)Spans[Math.Max(0, SpanBox.SelectedIndex)].Span.Hertz, BandmapSpanHertz = (int)Spans[Math.Max(0, SpanBox.SelectedIndex)].Hertz,
BandmapCentred = CentreBox.IsChecked == true, BandmapCentred = CentreBox.IsChecked == true,
}); });
Refresh(); Refresh();
@@ -94,7 +92,9 @@ public sealed partial class BandmapWindow : RefreshableWindow
(Frequency low, Frequency high) = Window(band, session.Position.Frequency); (Frequency low, Frequency high) = Window(band, session.Position.Frequency);
View.Low = low; View.Low = low;
View.High = high; View.High = high;
View.TickStep = StepFor(high.Hertz - low.Hertz); long shown = high.Hertz - low.Hertz;
View.TickStep = StepFor(shown);
View.DenseTicks = shown is > 40_000 and <= 200_000;
View.Spots = session.Bandmap.On(band); View.Spots = session.Bandmap.On(band);
View.Vfos = Vfos(); View.Vfos = Vfos();
View.Segments = session.BandPlan.For(band); View.Segments = session.BandPlan.For(band);
@@ -131,12 +131,10 @@ public sealed partial class BandmapWindow : RefreshableWindow
private (Frequency Low, Frequency High) Window(Band band, Frequency receiver) private (Frequency Low, Frequency High) Window(Band band, Frequency receiver)
{ {
(Frequency low, Frequency high) = BandmapSlice.Choose( long span = Spans[Math.Max(0, SpanBox.SelectedIndex)].Hertz;
band, (Frequency low, Frequency high) = span == PartOfBand
receiver, ? BandmapSlice.Part(band, session.BandPlan.For(band), receiver)
Spans[Math.Max(0, SpanBox.SelectedIndex)].Span.Hertz, : BandmapSlice.Choose(band, receiver, span, CentreBox.IsChecked == true, sliceLow);
CentreBox.IsChecked == true,
sliceLow);
sliceLow = low; sliceLow = low;
return (low, high); return (low, high);
} }
@@ -151,7 +149,7 @@ public sealed partial class BandmapWindow : RefreshableWindow
return; return;
} }
long shown = View.High.Hertz - View.Low.Hertz; long shown = View.High.Hertz - View.Low.Hertz;
long span = Spans[next].Span.Hertz; long span = Spans[next].Hertz;
if (span > 0) if (span > 0)
{ {
sliceLow = BandmapSlice.Zoom(View.Low, shown, span, zoom.At); sliceLow = BandmapSlice.Zoom(View.Low, shown, span, zoom.At);
+10
View File
@@ -20,6 +20,16 @@ public sealed record BandSegments(
public bool HasPhone => Band.High > CwHigh; public bool HasPhone => Band.High > CwHigh;
public bool HasDigital => DigitalHigh > DigitalLow; public bool HasDigital => DigitalHigh > DigitalLow;
/// The CW, digital or phone part of the band that holds the frequency.
public (Frequency Low, Frequency High) PartAt(Frequency frequency)
{
if (HasDigital && frequency >= DigitalLow && frequency <= DigitalHigh)
{
return (DigitalLow, DigitalHigh);
}
return frequency < CwHigh ? (CwLow, CwHigh) : (PhoneLow, PhoneHigh);
}
} }
/// The boundaries the bandmap paints. N1MM holds the same numbers in its /// The boundaries the bandmap paints. N1MM holds the same numbers in its
+8
View File
@@ -34,6 +34,14 @@ public static class BandmapLayout
return placed; return placed;
} }
private static readonly Frequency[] TickSteps =
[.. new double[] { 1, 2, 5, 10, 20, 50 }.Select(Frequency.FromKilohertz)];
/// The smallest round step (1, 2, 5, 10, 20 or 50 kHz) that keeps two
/// ticks at least `minGap` pixels apart, or the largest when none does.
public static Frequency StepFor(BandmapScale scale, double minGap) =>
TickSteps.FirstOrDefault(s => s.Hertz * scale.Height / scale.Span >= minGap, TickSteps[^1]);
/// The frequency ticks to draw, every `step` from the first round frequency /// The frequency ticks to draw, every `step` from the first round frequency
/// at or below the top of the scale. /// at or below the top of the scale.
public static IReadOnlyList<Frequency> Ticks(BandmapScale scale, Frequency step) public static IReadOnlyList<Frequency> Ticks(BandmapScale scale, Frequency step)
+14
View File
@@ -47,6 +47,20 @@ public static class BandmapSlice
return (Frequency.FromHertz(low), Frequency.FromHertz(low + span)); return (Frequency.FromHertz(low), Frequency.FromHertz(low + span));
} }
/// The CW, digital or phone part of the band the receiver is in, stretched
/// to include the receiver when it is outside the band. A band with no
/// boundaries in the band plan shows the whole band.
public static (Frequency Low, Frequency High) Part(
Band band,
BandSegments? segments,
Frequency receiver)
{
(Frequency low, Frequency high) = segments?.PartAt(receiver) ?? (band.Low, band.High);
return (
Frequency.FromHertz(Math.Min(low.Hertz, receiver.Hertz)),
Frequency.FromHertz(Math.Max(high.Hertz, receiver.Hertz)));
}
/// The bottom of a slice `newSpan` wide that keeps `anchor` at the same /// 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 /// 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. /// zooming with the wheel stays on the frequency under the pointer.
@@ -77,6 +77,19 @@ public class BandmapLayoutTests
Assert.Equal([14_005_000, 14_010_000], ticks.Select(t => t.Hertz)); Assert.Equal([14_005_000, 14_010_000], ticks.Select(t => t.Hertz));
} }
[Theory]
[InlineData(60, 590, 5)]
[InlineData(75, 590, 10)]
[InlineData(100, 590, 10)]
[InlineData(150, 590, 20)]
public void TheTickStepKeepsThreeLinesBetweenTicks(int spanKilohertz, double height, int expectedKilohertz)
{
BandmapScale scale = new(
Frequency.FromKilohertz(14_000), Frequency.FromKilohertz(14_000 + spanKilohertz), height);
Assert.Equal(expectedKilohertz, BandmapLayout.StepFor(scale, 3 * 16).Kilohertz);
}
[Fact] [Fact]
public void TheScaleTurnsPixelsBackIntoFrequencies() => public void TheScaleTurnsPixelsBackIntoFrequencies() =>
Assert.Equal(14_050_000, Scale().At(50).Hertz); Assert.Equal(14_050_000, Scale().At(50).Hertz);
@@ -61,4 +61,34 @@ public class BandmapSliceTests
Assert.Equal(Khz(14_005), low); Assert.Equal(Khz(14_005), low);
} }
private static readonly BandSegments Segments = new(
Bands.Band20M, CwHigh: Khz(14_150), DigitalLow: Khz(14_070), DigitalHigh: Khz(14_099));
[Theory]
[InlineData(14_025, 14_000, 14_150)]
[InlineData(14_080, 14_070, 14_099)]
[InlineData(14_250, 14_150, 14_350)]
public void ThePartOfTheBandIsTheOneTheReceiverIsIn(double receiver, double low, double high)
{
(Frequency from, Frequency to) = BandmapSlice.Part(Bands.Band20M, Segments, Khz(receiver));
Assert.Equal((Khz(low), Khz(high)), (from, to));
}
[Fact]
public void ABandWithoutBoundariesShowsTheWholeBandAsAPart()
{
(Frequency from, Frequency to) = BandmapSlice.Part(Bands.Band20M, null, Khz(14_025));
Assert.Equal((Bands.Band20M.Low, Bands.Band20M.High), (from, to));
}
[Fact]
public void ThePartIsStretchedToIncludeAReceiverOutsideTheBand()
{
(_, Frequency to) = BandmapSlice.Part(Bands.Band20M, Segments, Khz(14_360));
Assert.Equal(Khz(14_360), to);
}
} }
@@ -72,4 +72,5 @@ public class SpotDescriptionTests
Assert.Equal("14025.0 by SHACK-2, 41m ago", SpotDescription.Of(spot, Now)); Assert.Equal("14025.0 by SHACK-2, 41m ago", SpotDescription.Of(spot, Now));
} }
} }