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:
@@ -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
|
||||||
|
|||||||
@@ -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="−" 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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user