Draw the bandmap the way N1MM draws it

The bandmap was a list of frequencies and callsigns. It is now a frequency
scale with the stations written out beside it and a leader line from each
callsign back to where the station really is.

BandmapLayout does the placing and has no UI reference, so the rule is unit
tested: put the label centred on its frequency, and if it would cover the one
above, push it below instead. That is N1MM's CalculateOffset, and it is what
keeps three stations a hundred hertz apart readable.

The scale stops short of the top and bottom edges, or the first and last
frequency numbers come out cut in half.

VfoMarker draws the receiver as a bar as wide as the mode passes, rather than a
line. Only the receiver is fed: the radio does not report a transmit VFO or a
second radio yet, so VfoRole has the other two roles ready and nothing draws
them. Band-plan colouring of the scale is left out because the segments differ
by ITU region and there is no band-plan table to read them from.

Checked by running the program against a fake cluster node under Xvfb and
looking at the result: twelve spots, three of them stacked with fanned leaders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 16:15:01 +00:00
parent 2b5ab54e94
commit b9f0166777
11 changed files with 443 additions and 48 deletions

View File

@@ -48,6 +48,7 @@ scorer: red for a dupe, green for a new multiplier, blue for points.
| Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations | | Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations |
| Radio | hamlib `rigctld`, reconnecting on its own | | Radio | hamlib `rigctld`, reconnecting on its own |
| Cluster | DX cluster over telnet, spots feeding the bandmap, Alt+P to spot a station | | Cluster | DX cluster over telnet, spots feeding the bandmap, Alt+P to spot a station |
| Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines |
| Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message | | Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message |
| Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros | | Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros |
@@ -119,6 +120,28 @@ Correcting the country prefix by hand changes the score. The country file is a
best guess for calls it has no rule for, so what the contact says now wins over best guess for calls it has no rule for, so what the contact says now wins over
what the file says. what the file says.
### The bandmap
A frequency scale down the left with the stations written out beside it. Each
callsign sits level with its frequency, and when two stations are too close to
write one above the other the lower one moves down and a leader line runs back
to where it really is. That is what N1MM does, and the stacking follows its
rule: place the label centred on its frequency, and push it below the one above
if it would cover it.
The slice shown follows the receiver — 10, 20, 40 or 100 kHz, or the whole band.
Callsigns are coloured by the same scorer as the entry window, so a dupe reads
as a dupe here too. Clicking a callsign puts the radio there with the call
already in the entry window; clicking anywhere else just moves the radio.
The green bar on the scale is the receiver, as wide as the mode it is in.
`VfoRole` has transmit and second-radio bars ready, but nothing feeds them yet:
the radio does not report a transmit VFO or a second radio, so those bars stay
out rather than showing a guess.
Band-plan colouring of the scale is not there. The segments differ by ITU region
and the program has no band-plan table, so it would be guesswork.
### The DX cluster ### The DX cluster
**Config → Cluster** takes the node's address, a password for the few nodes that **Config → Cluster** takes the node's address, a password for the few nodes that
@@ -174,7 +197,7 @@ covered by plain unit tests.
Working: logging a contest end to end, live dupe and multiplier checking, eight Working: logging a contest end to end, live dupe and multiplier checking, eight
built-in contests plus user-defined ones, Cabrillo and ADIF export, ADIF import, built-in contests plus user-defined ones, Cabrillo and ADIF export, ADIF import,
the log, check, bandmap, score and packet windows, editing and deleting logged the log, check, graphical bandmap, score and packet windows, editing and deleting logged
contacts, radio control, DX cluster spots, contacts shared between networked contacts, radio control, DX cluster spots, contacts shared between networked
stations, and CW keying. stations, and CW keying.

View File

@@ -0,0 +1,142 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Nonemm.Core;
using Nonemm.Spotting;
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 class BandmapView : Control
{
private const double ScaleX = 52;
private const double LabelX = 96;
private const double TickLength = 6;
private const double LabelHeight = 15;
private const double TextSize = 12;
private const double VfoWidth = 4;
/// The scale stops short of the top and bottom edges so the first and last
/// frequency numbers are not cut in half.
private const double Inset = 9;
private static readonly IBrush ReceiveBrush = new SolidColorBrush(Color.FromRgb(0x27, 0xAE, 0x60));
private static readonly IBrush TransmitBrush = new SolidColorBrush(Color.FromRgb(0xC0, 0x39, 0x2B));
private static readonly IBrush SecondBrush = new SolidColorBrush(Color.FromRgb(0xE6, 0x7E, 0x22));
private readonly Typeface typeface = new(new FontFamily("monospace"));
private IReadOnlyList<BandmapLabel> placed = [];
private BandmapScale scale = new(Bands.Band20M.Low, Bands.Band20M.High, 1);
public BandmapView() => Focusable = true;
public IReadOnlyList<Spot> Spots { get; set; } = [];
public IReadOnlyList<VfoMarker> Vfos { get; set; } = [];
public Frequency Low { get; set; } = Bands.Band20M.Low;
public Frequency High { get; set; } = Bands.Band20M.High;
/// How the callsign is coloured: the same scorer as the entry window.
public Func<Spot, IBrush> ColourOf { get; set; } = _ => Brushes.Gray;
/// The step between the frequency numbers down the scale.
public Frequency TickStep { get; set; } = Frequency.FromKilohertz(5);
public event EventHandler<Spot>? SpotPicked;
/// Clicking where no station is asks for the radio, not a callsign.
public event EventHandler<Frequency>? FrequencyPicked;
public override void Render(DrawingContext context)
{
scale = new BandmapScale(Low, High, Math.Max(1, Bounds.Height - (2 * Inset)));
IBrush ink = Foreground();
context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
DrawScale(context, ink);
DrawVfos(context);
placed = [.. BandmapLayout.Place(Spots, scale, LabelHeight)
.Select(l => l with { MarkY = l.MarkY + Inset, LabelY = l.LabelY + Inset })];
foreach (BandmapLabel label in placed)
{
DrawLabel(context, label, ink);
}
}
private void DrawScale(DrawingContext context, IBrush ink)
{
Pen line = new(Opaque(ink, 0.5), 1);
context.DrawLine(line, new Point(ScaleX, Inset), new Point(ScaleX, Inset + scale.Height));
foreach (Frequency tick in BandmapLayout.Ticks(scale, TickStep))
{
double y = Math.Round(scale.YFor(tick) + Inset) + 0.5;
context.DrawLine(line, new Point(ScaleX - TickLength, y), new Point(ScaleX, y));
FormattedText text = Text(
tick.Kilohertz.ToString("0.#", CultureInfo.InvariantCulture),
Opaque(ink, 0.6));
context.DrawText(text, new Point(ScaleX - TickLength - 4 - text.Width, y - (text.Height / 2)));
}
}
private void DrawVfos(DrawingContext context)
{
foreach (VfoMarker vfo in Vfos)
{
double top = scale.YFor(vfo.Low) + Inset;
double bottom = scale.YFor(vfo.High) + Inset;
// a narrow mode would be less than a pixel tall
double height = Math.Max(3, bottom - top);
context.FillRectangle(
BrushFor(vfo.Role),
new Rect(ScaleX - (VfoWidth / 2), top, VfoWidth, height));
}
}
private void DrawLabel(DrawingContext context, BandmapLabel label, IBrush ink)
{
IBrush colour = ColourOf(label.Spot);
context.DrawLine(
new Pen(Opaque(ink, 0.35), 1),
new Point(ScaleX + 2, label.MarkY),
new Point(LabelX - 2, label.LabelY + (LabelHeight / 2)));
context.DrawText(Text(label.Spot.Call.Text, colour), new Point(LabelX, label.LabelY));
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
Point at = e.GetPosition(this);
foreach (BandmapLabel label in placed)
{
if (at.X >= LabelX && at.Y >= label.LabelY && at.Y <= label.LabelY + LabelHeight)
{
SpotPicked?.Invoke(this, label.Spot);
return;
}
}
FrequencyPicked?.Invoke(this, scale.At(at.Y - Inset));
}
private FormattedText Text(string text, IBrush brush) =>
new(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, TextSize, brush);
private IBrush Foreground() =>
this.FindResource("SystemControlForegroundBaseHighBrush") as IBrush ?? Brushes.Gray;
private static IBrush Opaque(IBrush ink, double opacity) =>
ink is ISolidColorBrush solid
? new SolidColorBrush(solid.Color, opacity)
: ink;
private static IBrush BrushFor(VfoRole role) => role switch
{
VfoRole.Transmit => TransmitBrush,
VfoRole.Second => SecondBrush,
_ => ReceiveBrush,
};
}

View File

@@ -2,12 +2,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Nonemm.App.Windows" xmlns:local="using:Nonemm.App.Windows"
x:Class="Nonemm.App.Windows.BandmapWindow" x:Class="Nonemm.App.Windows.BandmapWindow"
Title="Bandmap" Width="320" Height="520"> Title="Bandmap" Width="280" Height="620">
<DockPanel> <DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="6,6,6,2" Spacing="6"> <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" />
<CheckBox Name="ThisBandOnly" Content="this band" IsChecked="True" FontSize="11" /> <ComboBox Name="SpanBox" FontSize="11" Width="96" />
</StackPanel> </StackPanel>
<ListBox Name="Spots" FontFamily="monospace" FontSize="13" /> <TextBlock DockPanel.Dock="Bottom" Name="StatusText" Margin="6,2,6,6"
FontSize="11" Opacity="0.7" />
<local:BandmapView Name="View" />
</DockPanel> </DockPanel>
</local:RefreshableWindow> </local:RefreshableWindow>

View File

@@ -1,17 +1,26 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Threading;
using Nonemm.Contests; using Nonemm.Contests;
using Nonemm.Core; using Nonemm.Core;
using Nonemm.Spotting; using Nonemm.Spotting;
namespace Nonemm.App.Windows; namespace Nonemm.App.Windows;
/// The band as a list of stations in frequency order: cluster spots and every /// The band drawn as N1MM draws it: a frequency scale with the receiver on it
/// station this log has worked. Clicking one puts the radio on it with the /// and the stations written out beside it. Clicking a callsign puts the radio
/// callsign already in the entry window. /// there with the call already in the entry window; clicking anywhere else just
/// moves the radio.
public sealed partial class BandmapWindow : RefreshableWindow public sealed partial class BandmapWindow : RefreshableWindow
{ {
private static readonly (string Label, Frequency Span)[] Spans =
[
("10 kHz", Frequency.FromKilohertz(10)),
("20 kHz", Frequency.FromKilohertz(20)),
("40 kHz", Frequency.FromKilohertz(40)),
("100 kHz", Frequency.FromKilohertz(100)),
("whole band", Frequency.Zero),
];
private readonly AppSession session; private readonly AppSession session;
private readonly Action<Frequency, string> tune; private readonly Action<Frequency, string> tune;
@@ -20,51 +29,74 @@ public sealed partial class BandmapWindow : RefreshableWindow
this.session = session; this.session = session;
this.tune = tune; this.tune = tune;
InitializeComponent(); InitializeComponent();
Spots.SelectionChanged += (_, _) => OnPicked(); SpanBox.ItemsSource = Spans.Select(s => s.Label).ToList();
ThisBandOnly.IsCheckedChanged += (_, _) => Refresh(); SpanBox.SelectedIndex = 2;
session.Bandmap.Changed += (_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(Refresh); SpanBox.SelectionChanged += (_, _) => Refresh();
View.ColourOf = Colour;
View.SpotPicked += (_, spot) => tune(spot.Frequency, spot.Call.Text);
View.FrequencyPicked += (_, frequency) => tune(frequency, "");
session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
Refresh(); Refresh();
} }
public override void Refresh() public override void Refresh()
{ {
session.Bandmap.DropOlderThan(DateTime.UtcNow); session.Bandmap.DropOlderThan(DateTime.UtcNow);
Band? band = session.Logging is null ? null : Bands.ForFrequency(session.Logging.Frequency); Band? band = session.Logging is null ? null : Bands.ForFrequency(session.Logging.Frequency);
BandText.Text = band?.Name ?? "no band"; BandText.Text = band?.Name ?? "no band";
if (session.Logging is null || band is null)
IReadOnlyList<Spot> spots = ThisBandOnly.IsChecked == true && band is not null {
? session.Bandmap.On(band) View.Spots = [];
: session.Bandmap.All(); View.Vfos = [];
Spots.ItemsSource = spots.Select(BuildRow).ToList(); StatusText.Text = session.Logging is null ? "no contest is open" : "off band";
View.InvalidateVisual();
return;
} }
private Control BuildRow(Spot spot) (Frequency low, Frequency high) = Window(band, session.Logging.Frequency);
{ View.Low = low;
StackPanel row = new() { Orientation = Orientation.Horizontal, Tag = spot }; View.High = high;
row.Children.Add(new TextBlock View.TickStep = StepFor(high.Hertz - low.Hertz);
{ View.Spots = session.Bandmap.On(band);
Text = spot.Frequency.Kilohertz.ToString("0.0").PadLeft(8), View.Vfos = Vfos();
FontFamily = new FontFamily("monospace"), StatusText.Text = $"{View.Spots.Count} stations · {low.Kilohertz:0.#} to {high.Kilohertz:0.#} kHz";
Opacity = 0.75, View.InvalidateVisual();
Margin = new Avalonia.Thickness(0, 0, 8, 0),
});
row.Children.Add(new TextBlock
{
Text = spot.Call.Text,
FontFamily = new FontFamily("monospace"),
Foreground = Verdicts.Colour(VerdictFor(spot)),
});
row.Children.Add(new TextBlock
{
Text = spot.Source == SpotSource.Log ? " worked" : $" {spot.Spotter}",
FontSize = 11,
Opacity = 0.6,
Margin = new Avalonia.Thickness(6, 2, 0, 0),
});
return row;
} }
/// Only the receiver is known. The radio does not report the transmit VFO
/// or a second radio yet, so those bars are not drawn; `VfoRole` has the
/// roles ready for when it does.
private IReadOnlyList<VfoMarker> Vfos() =>
session.Logging is null
? []
: [VfoMarker.Centred(
session.Logging.Frequency,
Modes.Bandwidth(session.Logging.Mode),
VfoRole.Receive)];
/// The slice to show, centred on the receiver and kept inside the band.
private (Frequency Low, Frequency High) Window(Band band, Frequency centre)
{
Frequency span = Spans[Math.Max(0, SpanBox.SelectedIndex)].Span;
if (span.Hertz == 0 || span.Hertz >= band.High.Hertz - band.Low.Hertz)
{
return (band.Low, band.High);
}
long half = span.Hertz / 2;
long low = Math.Clamp(centre.Hertz - half, band.Low.Hertz, band.High.Hertz - span.Hertz);
return (Frequency.FromHertz(low), Frequency.FromHertz(low + span.Hertz));
}
private static Frequency StepFor(long span) => span switch
{
<= 20_000 => Frequency.FromKilohertz(2),
<= 60_000 => Frequency.FromKilohertz(5),
<= 200_000 => Frequency.FromKilohertz(20),
_ => Frequency.FromKilohertz(50),
};
private IBrush Colour(Spot spot) => Verdicts.Colour(VerdictFor(spot));
private Verdict? VerdictFor(Spot spot) private Verdict? VerdictFor(Spot spot)
{ {
if (session.Logging is null) if (session.Logging is null)
@@ -81,12 +113,4 @@ public sealed partial class BandmapWindow : RefreshableWindow
ContestName = session.Logging.Contest.Name, ContestName = session.Logging.Contest.Name,
}); });
} }
private void OnPicked()
{
if (Spots.SelectedItem is Control { Tag: Spot spot })
{
tune(spot.Frequency, spot.Call.Text);
}
}
} }

View File

@@ -37,6 +37,14 @@ public static class Modes
return ByName.TryGetValue(key, out Mode? mode) ? mode : null; return ByName.TryGetValue(key, out Mode? mode) ? mode : null;
} }
/// Roughly what a receiver passes in this mode. The bandmap draws the
/// receiver as a bar this wide rather than a single line.
public static Frequency Bandwidth(Mode mode) => mode.Category switch
{
ModeCategory.Phone => Frequency.FromHertz(2_700),
_ => Frequency.FromHertz(500),
};
/// The sideband convention: LSB below 10 MHz, USB above, and on 60 metres. /// The sideband convention: LSB below 10 MHz, USB above, and on 60 metres.
public static Mode ForSideband(Frequency f) => public static Mode ForSideband(Frequency f) =>
f.Hertz < 10_000_000 && !Bands.Band60M.Contains(f) ? Lsb : Usb; f.Hertz < 10_000_000 && !Bands.Band60M.Contains(f) ? Lsb : Usb;

View File

@@ -0,0 +1,6 @@
namespace Nonemm.Spotting;
/// One callsign on the bandmap. `MarkY` is where the station really is on the
/// scale and `LabelY` is the top of the text, which is pushed down when the
/// station above is too close. The leader line joins the two.
public sealed record BandmapLabel(Spot Spot, double MarkY, double LabelY);

View File

@@ -0,0 +1,52 @@
using Nonemm.Core;
namespace Nonemm.Spotting;
/// Works out where each callsign goes. Two stations a hundred hertz apart would
/// have their text on top of each other, so a label that would cover the one
/// above is pushed below it and a leader line runs back to the real frequency.
/// This is what N1MM does.
public static class BandmapLayout
{
/// N1MM leaves this much between two labels it had to separate.
private const double Gap = 0.5;
public static IReadOnlyList<BandmapLabel> Place(
IEnumerable<Spot> spots,
BandmapScale scale,
double labelHeight)
{
List<BandmapLabel> placed = [];
double? above = null;
foreach (Spot spot in spots
.Where(s => scale.Shows(s.Frequency))
.OrderBy(s => s.Frequency.Hertz))
{
double markY = scale.YFor(spot.Frequency);
double labelY = markY - (labelHeight / 2);
if (above is { } previous && previous + labelHeight > labelY)
{
labelY = previous + labelHeight + Gap;
}
placed.Add(new BandmapLabel(spot, markY, labelY));
above = labelY;
}
return placed;
}
/// The frequency ticks to draw, every `step` from the first round frequency
/// at or below the top of the scale.
public static IReadOnlyList<Frequency> Ticks(BandmapScale scale, Frequency step)
{
List<Frequency> ticks = [];
long first = scale.Low.Hertz - (scale.Low.Hertz % step.Hertz);
for (long at = first; at <= scale.High.Hertz; at += step.Hertz)
{
if (at >= scale.Low.Hertz)
{
ticks.Add(Frequency.FromHertz(at));
}
}
return ticks;
}
}

View File

@@ -0,0 +1,19 @@
using Nonemm.Core;
namespace Nonemm.Spotting;
/// The slice of a band the bandmap is showing, and how it maps to pixels.
/// Frequency runs down the window, so `Low` is at the top.
public sealed record BandmapScale(Frequency Low, Frequency High, double Height)
{
public long Span => Math.Max(1, High.Hertz - Low.Hertz);
public double YFor(Frequency frequency) =>
(frequency.Hertz - Low.Hertz) * Height / Span;
public Frequency At(double y) =>
Frequency.FromHertz(Low.Hertz + (long)Math.Round(y * Span / Math.Max(1, Height)));
public bool Shows(Frequency frequency) =>
frequency >= Low && frequency <= High;
}

View File

@@ -0,0 +1,15 @@
using Nonemm.Core;
namespace Nonemm.Spotting;
/// Where a receiver or transmitter is sitting, drawn on the bandmap as a bar
/// covering what it hears or occupies rather than a single line.
public sealed record VfoMarker(Frequency Low, Frequency High, VfoRole Role)
{
/// The bar for a receiver on `centre`, as wide as the mode it is in.
public static VfoMarker Centred(Frequency centre, Frequency width, VfoRole role) =>
new(
Frequency.FromHertz(centre.Hertz - (width.Hertz / 2)),
Frequency.FromHertz(centre.Hertz + (width.Hertz / 2)),
role);
}

View File

@@ -0,0 +1,9 @@
namespace Nonemm.Spotting;
/// Which of a station's receivers or transmitters a bandmap bar stands for.
public enum VfoRole
{
Receive,
Transmit,
Second,
}

View File

@@ -0,0 +1,95 @@
using Nonemm.Core;
namespace Nonemm.Spotting.Tests;
public class BandmapLayoutTests
{
private const double LabelHeight = 10;
private static readonly DateTime Now = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
/// 14000 to 14100 kHz over 100 pixels, so one pixel is one kilohertz.
private static BandmapScale Scale() =>
new(Frequency.FromKilohertz(14_000), Frequency.FromKilohertz(14_100), 100);
private static Spot At(double kilohertz, string call = "DL1ABC") =>
new(Callsign.Parse(call), Frequency.FromKilohertz(kilohertz), Now, SpotSource.Cluster);
[Fact]
public void ALabelSitsCentredOnItsFrequency()
{
IReadOnlyList<BandmapLabel> placed = BandmapLayout.Place([At(14_050)], Scale(), LabelHeight);
Assert.Equal(50, placed[0].MarkY);
Assert.Equal(45, placed[0].LabelY);
}
[Fact]
public void LabelsComeOutInFrequencyOrder()
{
IReadOnlyList<BandmapLabel> placed = BandmapLayout.Place(
[At(14_080, "JA1XYZ"), At(14_010, "DL1ABC")],
Scale(),
LabelHeight);
Assert.Equal(["DL1ABC", "JA1XYZ"], placed.Select(l => l.Spot.Call.Text));
}
/// Two stations a hundred hertz apart would have their text on top of each
/// other, so the lower one moves down and its leader line does the work.
[Fact]
public void ALabelThatWouldCoverTheOneAboveIsPushedDown()
{
IReadOnlyList<BandmapLabel> placed = BandmapLayout.Place(
[At(14_050, "DL1ABC"), At(14_050.1, "JA1XYZ")],
Scale(),
LabelHeight);
Assert.Equal(45, placed[0].LabelY);
Assert.Equal(55.5, placed[1].LabelY);
// the leader still points at where the station really is
Assert.Equal(50.1, placed[1].MarkY, 3);
}
[Fact]
public void ALabelWithRoomKeepsItsOwnPlace()
{
IReadOnlyList<BandmapLabel> placed = BandmapLayout.Place(
[At(14_010, "DL1ABC"), At(14_090, "JA1XYZ")],
Scale(),
LabelHeight);
Assert.Equal(5, placed[0].LabelY);
Assert.Equal(85, placed[1].LabelY);
}
[Fact]
public void AStationOutsideTheSliceIsNotDrawn() =>
Assert.Empty(BandmapLayout.Place([At(14_200)], Scale(), LabelHeight));
[Fact]
public void TheTicksStartAtTheFirstRoundFrequencyInTheSlice()
{
IReadOnlyList<Frequency> ticks = BandmapLayout.Ticks(
new BandmapScale(Frequency.FromKilohertz(14_003), Frequency.FromKilohertz(14_012), 100),
Frequency.FromKilohertz(5));
Assert.Equal([14_005_000, 14_010_000], ticks.Select(t => t.Hertz));
}
[Fact]
public void TheScaleTurnsPixelsBackIntoFrequencies() =>
Assert.Equal(14_050_000, Scale().At(50).Hertz);
[Fact]
public void AReceiverBarIsAsWideAsTheModeItIsIn()
{
VfoMarker vfo = VfoMarker.Centred(
Frequency.FromKilohertz(14_025),
Frequency.FromHertz(500),
VfoRole.Receive);
Assert.Equal(14_024_750, vfo.Low.Hertz);
Assert.Equal(14_025_250, vfo.High.Hertz);
}
}