Paint the band the way N1MM paints it

Behind the bandmap scale, the CW part of the band is one shade and the phone
part another, with a third for a digital block. That is what N1MM's graphical
bandmap does: ResizeBandPanels puts a CW, a Digi and an SSB panel behind the
scale, sized from its CWBands, DigiBands and SSBBands tables. Anything past a
band edge is shaded red, which is the only place the edges themselves show, and
it only appears now that the slice can reach past an edge.

The defaults are N1MM's: CW ends at 1840, 3600, 7125, 14150 and so on, and the
digital pair is empty until somebody fills it in, exactly as N1MM ships. They
are one worldwide set rather than one per ITU region, which is also N1MM's
answer — the reason recorded for leaving this out, that the segments differ by
region, was wrong about what N1MM holds. 60M and everything above 2M get no
entry, because N1MM's own numbers there contradict themselves: its 1.25M CW top
is below its phone start, and its 70CM CW top is above the band.

Config ▸ Sub bands edits them, one row per band in kilohertz, with a Defaults
button. Only the rows that differ are written to the settings file, so a plan
nobody has touched leaves nothing behind. BandPlan is an object rather than a
static table, so the stored changes layer over the defaults and the bandmap
picks up a save without a restart.

The numbers are a drawing aid, not a licence. Nothing stops a contact being
logged either side of a boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 23:01:56 +00:00
parent e6d0b58305
commit d084789ed2
12 changed files with 431 additions and 7 deletions

View File

@@ -229,8 +229,21 @@ The bars on the scale are as wide as the mode passes: green where you are
listening, red where the radio transmits when working split, orange for the
other radio of a two-radio station.
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.
Behind the scale the CW part of the band is one shade and the phone part
another, with a third shade for a digital block where one is set, and the CW
boundary named in the status line. Anything past a band edge is shaded red,
which is where the edges show. N1MM paints the same blocks from its `CWBands`,
`DigiBands` and `SSBBands` tables.
**Config → Sub bands** edits them, one row per band, in kilohertz, with a
Defaults button. The digital pair starts empty, as N1MM's does, so nothing is
painted for it until it is filled in. Only the rows that differ from the
defaults are written to the settings file.
The defaults are one worldwide set, as N1MM's are, not one per ITU region. They
are a drawing aid: a contact logs either side of a boundary just the same.
Bands where N1MM's own numbers contradict themselves — 60M, and everything
above 2M — get no shading rather than a guess.
### Band edges

View File

@@ -45,10 +45,6 @@ data. The file is read without them.
the contest rules score it, but there is no digital window: no decoding, no
transmitting, no interface to fldigi, MMTTY or similar.
**Band-plan colouring of the bandmap scale.** N1MM colours the scale by CW,
digital and phone segment. The segments differ by ITU region and there is no
band-plan table here, so it is left out rather than guessed.
**Contest coverage.** Eight contests are built in, plus whatever `.udc` files
are in the user-defined folder. N1MM ships well over a hundred. Opening a log
from a contest not in the registry fails with the contest name, which is the
@@ -64,6 +60,12 @@ thread, and `AppSession.TakeFromNetwork` reopens the whole contest to do it.
That is correct but heavy, and it discards what is typed in an entry window on
another radio.
**The band plan covers 160M to 2M only.** 60M and everything above 2M get no
CW, digital or phone shading on the bandmap, because N1MM's own numbers for
those bands contradict themselves — its CW top for 1.25M is below its phone
start, and its 70CM CW top is above the band. Config ▸ Sub bands cannot add a
band that has no default.
**Available mults and Qs is only as good as the bandmap.** It lists what is
spotted, so a multiplier nobody has spotted is not there. N1MM's window also
shows the mults a contest has and nobody has worked, which needs a list of every

View File

@@ -36,6 +36,7 @@ public sealed class AppSession : IDisposable
History = LoadCallHistory(settings.CallHistoryFile);
Registry = ContestRegistry.FromFolder(paths.UserDefinedContests, out IReadOnlyList<string> problems);
UserDefinedContestProblems = problems;
BandPlan = settings.ToBandPlan();
}
public UserPaths Paths { get; }
@@ -44,6 +45,9 @@ public sealed class AppSession : IDisposable
public ContestRegistry Registry { get; private set; }
/// Where the bandmap paints the CW, digital and phone parts of a band.
public BandPlan BandPlan { get; private set; }
public IReadOnlyList<string> UserDefinedContestProblems { get; }
public CountryFile? Countries { get; private set; }
@@ -101,6 +105,7 @@ public sealed class AppSession : IDisposable
public void Save(Settings settings)
{
Settings = settings;
BandPlan = settings.ToBandPlan();
settings.Save(Paths.SettingsFile);
Changed?.Invoke(this, EventArgs.Empty);
}

View File

@@ -62,6 +62,10 @@ public sealed record Settings
public int KeyerSpeed { get; init; } = 28;
/// Sub-band boundaries the operator has changed. Empty means the defaults
/// in `BandPlan.Default`; an entry replaces one band's boundaries.
public IReadOnlyList<StoredSubBand> SubBands { get; init; } = [];
/// The twelve function key messages, keyed F1 to F12.
public IReadOnlyList<string> CwMessages { get; init; } = [];
@@ -91,6 +95,20 @@ public sealed record Settings
}
}
/// The defaults with the operator's changes applied over them.
public BandPlan ToBandPlan()
{
BandPlan plan = BandPlan.Default;
foreach (StoredSubBand stored in SubBands)
{
if (stored.ToSegments() is { } segments)
{
plan = plan.With(segments);
}
}
return plan;
}
public void Save(string path) =>
File.WriteAllText(path, JsonSerializer.Serialize(this, Json));
@@ -112,6 +130,38 @@ public sealed record Settings
};
}
/// One band's sub-band boundaries as they are stored. Kilohertz, because that
/// is what the operator types into the editor.
public sealed record StoredSubBand
{
public string Band { get; init; } = "";
public double CwHigh { get; init; }
public double DigitalLow { get; init; }
public double DigitalHigh { get; init; }
/// Null when the band name is not one we know, or the CW boundary is
/// missing: an entry that cannot be read leaves the default in place.
public BandSegments? ToSegments() =>
Bands.Named(Band) is { } band && CwHigh > 0
? new BandSegments(
band,
Frequency.FromKilohertz(CwHigh),
Frequency.FromKilohertz(DigitalLow),
Frequency.FromKilohertz(DigitalHigh))
: null;
public static StoredSubBand From(BandSegments segments) => new()
{
Band = segments.Band.Name,
CwHigh = segments.CwHigh.Kilohertz,
DigitalLow = segments.DigitalLow.Kilohertz,
DigitalHigh = segments.DigitalHigh.Kilohertz,
};
}
/// One radio's `rigctld`.
public sealed record StoredRadio
{

View File

@@ -0,0 +1,20 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.SubBandsDialog"
Title="Sub bands" Width="460" Height="460"
WindowStartupLocation="CenterOwner">
<DockPanel Margin="14">
<TextBlock DockPanel.Dock="Top" TextWrapping="Wrap" FontSize="11" Opacity="0.7"
Margin="0,0,0,10"
Text="Kilohertz. CW runs from the bottom of the band to the CW end, phone from there to the top. Leave the digital pair empty for no digital block. Blank the CW end to go back to the default." />
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
HorizontalAlignment="Right" Spacing="6" Margin="0,10,0,0">
<Button Content="Defaults" Click="OnDefaults" />
<Button Content="Cancel" Click="OnCancel" />
<Button Content="Save" Click="OnSave" IsDefault="True" />
</StackPanel>
<ScrollViewer>
<Grid Name="Table" ColumnDefinitions="60,*,*,*" />
</ScrollViewer>
</DockPanel>
</Window>

View File

@@ -0,0 +1,125 @@
using System.Globalization;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Nonemm.App.Configuration;
using Nonemm.Core;
namespace Nonemm.App.Dialogs;
/// The CW, digital and phone boundaries the bandmap paints, one row per band.
/// N1MM has the same editor under Config; the numbers are a drawing aid, so a
/// row that cannot be read is dropped rather than refused.
public sealed partial class SubBandsDialog : Window
{
private readonly Settings settings;
private readonly List<Row> rows = [];
public SubBandsDialog(Settings settings)
{
this.settings = settings;
InitializeComponent();
Build(settings.ToBandPlan());
}
private void Build(BandPlan plan)
{
Table.Children.Clear();
Table.RowDefinitions.Clear();
rows.Clear();
AddHeader();
int at = 1;
foreach (BandSegments segments in BandPlan.Default.All)
{
BandSegments shown = plan.For(segments.Band) ?? segments;
rows.Add(AddRow(at++, shown));
}
}
private void AddHeader()
{
Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
string[] headings = ["Band", "CW ends", "Digital from", "Digital to"];
for (int column = 0; column < headings.Length; column++)
{
TextBlock text = new()
{
Text = headings[column],
FontSize = 11,
Opacity = 0.7,
Margin = new Avalonia.Thickness(0, 0, 6, 4),
};
Grid.SetColumn(text, column);
Table.Children.Add(text);
}
}
private Row AddRow(int at, BandSegments segments)
{
Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
TextBlock name = new()
{
Text = segments.Band.Name,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
Margin = new Avalonia.Thickness(0, 2, 6, 2),
};
Grid.SetRow(name, at);
Table.Children.Add(name);
TextBox cw = Box(at, 1, segments.CwHigh);
TextBox digitalLow = Box(at, 2, segments.HasDigital ? segments.DigitalLow : Frequency.Zero);
TextBox digitalHigh = Box(at, 3, segments.HasDigital ? segments.DigitalHigh : Frequency.Zero);
return new Row(segments.Band, cw, digitalLow, digitalHigh);
}
private TextBox Box(int at, int column, Frequency value)
{
TextBox box = new()
{
Text = value.Hertz == 0 ? "" : value.Kilohertz.ToString("0.#", CultureInfo.InvariantCulture),
Margin = new Avalonia.Thickness(0, 2, 6, 2),
};
Grid.SetRow(box, at);
Grid.SetColumn(box, column);
Table.Children.Add(box);
return box;
}
private void OnDefaults(object? sender, RoutedEventArgs e) => Build(BandPlan.Default);
private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
{
SubBands = rows.Select(r => r.ToStored()).OfType<StoredSubBand>().ToList(),
});
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
private sealed record Row(Band Band, TextBox CwHigh, TextBox DigitalLow, TextBox DigitalHigh)
{
/// Null when the row is the default for that band, so a plan nobody has
/// touched stays out of the settings file.
public StoredSubBand? ToStored()
{
BandSegments? mine = Segments();
return mine is null || mine == BandPlan.Default.For(Band)
? null
: StoredSubBand.From(mine);
}
private BandSegments? Segments()
{
if (Kilohertz(CwHigh) is not { } cw)
{
return null;
}
Frequency low = Kilohertz(DigitalLow) ?? Frequency.Zero;
Frequency high = Kilohertz(DigitalHigh) ?? Frequency.Zero;
return new BandSegments(Band, cw, low, high);
}
private static Frequency? Kilohertz(TextBox box) =>
double.TryParse(box.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)
&& value > 0
? Frequency.FromKilohertz(value)
: null;
}
}

View File

@@ -28,6 +28,13 @@ public sealed class BandmapView : Control
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 static readonly IBrush CwBrush = new SolidColorBrush(Color.FromRgb(0x29, 0x80, 0xB9), 0.10);
private static readonly IBrush PhoneBrush = new SolidColorBrush(Color.FromRgb(0x27, 0xAE, 0x60), 0.10);
private static readonly IBrush DigitalBrush = new SolidColorBrush(Color.FromRgb(0x8E, 0x44, 0xAD), 0.13);
/// Past the band edge. The slice can reach there when the radio does.
private static readonly IBrush OutsideBrush = new SolidColorBrush(Color.FromRgb(0xC0, 0x39, 0x2B), 0.13);
private readonly Typeface typeface = new(new FontFamily("monospace"));
private IReadOnlyList<BandmapLabel> placed = [];
private BandmapScale scale = new(Bands.Band20M.Low, Bands.Band20M.High, 1);
@@ -42,6 +49,10 @@ public sealed class BandmapView : Control
public Frequency High { get; set; } = Bands.Band20M.High;
/// The CW and phone parts of the band, painted behind the scale. Null for a
/// band with no boundary in the band plan, and then nothing is painted.
public BandSegments? Segments { get; set; }
/// How the callsign is coloured: the same scorer as the entry window.
public Func<Spot, IBrush> ColourOf { get; set; } = _ => Brushes.Gray;
@@ -58,6 +69,7 @@ public sealed class BandmapView : Control
scale = new BandmapScale(Low, High, Math.Max(1, Bounds.Height - (2 * Inset)));
IBrush ink = Foreground();
context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
DrawSegments(context);
DrawScale(context, ink);
DrawVfos(context);
placed = [.. BandmapLayout.Place(Spots, scale, LabelHeight)
@@ -68,6 +80,39 @@ public sealed class BandmapView : Control
}
}
/// N1MM paints the CW, digital and phone parts of the band behind the
/// scale, the digital block over the other two.
/// Anything past a band edge is painted as out of band, which is the only
/// place the edges themselves show.
private void DrawSegments(DrawingContext context)
{
if (Segments is not { } segments)
{
return;
}
Fill(context, segments.CwLow, segments.CwHigh, CwBrush);
if (segments.HasPhone)
{
Fill(context, segments.PhoneLow, segments.PhoneHigh, PhoneBrush);
}
if (segments.HasDigital)
{
Fill(context, segments.DigitalLow, segments.DigitalHigh, DigitalBrush);
}
Fill(context, Low, segments.CwLow, OutsideBrush);
Fill(context, segments.PhoneHigh, High, OutsideBrush);
}
private void Fill(DrawingContext context, Frequency from, Frequency to, IBrush brush)
{
double top = Math.Max(0, scale.YFor(from)) + Inset;
double bottom = Math.Min(scale.Height, scale.YFor(to)) + Inset;
if (bottom > top)
{
context.FillRectangle(brush, new Rect(0, top, Bounds.Width, bottom - top));
}
}
private void DrawScale(DrawingContext context, IBrush ink)
{
Pen line = new(Opaque(ink, 0.5), 1);

View File

@@ -49,6 +49,7 @@ public sealed partial class BandmapWindow : RefreshableWindow
{
View.Spots = [];
View.Vfos = [];
View.Segments = null;
StatusText.Text = session.Position is null ? "no contest is open" : "off band";
View.InvalidateVisual();
return;
@@ -60,10 +61,18 @@ public sealed partial class BandmapWindow : RefreshableWindow
View.TickStep = StepFor(high.Hertz - low.Hertz);
View.Spots = session.Bandmap.On(band);
View.Vfos = Vfos();
StatusText.Text = $"{View.Spots.Count} stations · {low.Kilohertz:0.#} to {high.Kilohertz:0.#} kHz";
View.Segments = session.BandPlan.For(band);
StatusText.Text =
$"{View.Spots.Count} stations · {low.Kilohertz:0.#} to {high.Kilohertz:0.#} kHz{Split(band)}";
View.InvalidateVisual();
}
/// The band plan boundary, so the two shades behind the scale have a name.
private string Split(Band band) =>
session.BandPlan.For(band) is { HasPhone: true } segments
? $" · CW/phone {segments.CwHigh.Kilohertz:0.#}"
: "";
/// Green for where the operator is listening, red for where the radio
/// transmits when working split, orange for the other radio of a two-radio
/// station.

View File

@@ -353,6 +353,24 @@ public sealed partial class EntryWindow
: $"{Path.GetFileName(path)} holds no calls we could read");
}
/// The bandmap's CW, digital and phone boundaries.
private async void OnSubBandSettings(object? sender, RoutedEventArgs e)
{
SubBandsDialog dialog = new(session.Settings);
Settings? updated = await dialog.ShowDialog<Settings?>(this);
if (updated is null)
{
return;
}
session.Save(updated);
Status(updated.SubBands.Count switch
{
0 => "sub bands back to the defaults",
1 => "1 band changed from the defaults",
int changed => $"{changed} bands changed from the defaults",
});
}
private async void OnDownloadCountryFile(object? sender, RoutedEventArgs e) =>
await Download(
downloader => downloader.DownloadCountryFileAsync(session.Paths.CountryFile),

View File

@@ -59,6 +59,7 @@
<MenuItem Header="_Keyer and messages…" Click="OnKeyerSettings" />
<Separator />
<MenuItem Header="Call _History File…" Click="OnCallHistoryFile" />
<MenuItem Header="S_ub bands…" Click="OnSubBandSettings" />
<Separator />
<MenuItem Header="Download Country _File" Click="OnDownloadCountryFile" />
<MenuItem Header="Download Check _Partial File" Click="OnDownloadCallDatabase" />

View File

@@ -0,0 +1,75 @@
namespace Nonemm.Core;
/// Where the CW, digital and phone parts of one band are. The digital part is
/// empty until somebody fills it in, which is how N1MM ships as well: its
/// `DigiBands` table is all zeros and its CW and SSB tables meet at one
/// boundary.
public sealed record BandSegments(
Band Band,
Frequency CwHigh,
Frequency DigitalLow = default,
Frequency DigitalHigh = default)
{
public Frequency CwLow => Band.Low;
public Frequency PhoneLow => CwHigh;
public Frequency PhoneHigh => Band.High;
/// False on 30M, which has no phone allocation.
public bool HasPhone => Band.High > CwHigh;
public bool HasDigital => DigitalHigh > DigitalLow;
}
/// The boundaries the bandmap paints. N1MM holds the same numbers in its
/// `CWBands`, `DigiBands` and `SSBBands` tables and lets the operator edit
/// them; so does this, through Config ▸ Sub bands.
///
/// The defaults are one worldwide set, as N1MM's are, not one per ITU region.
/// They are a drawing aid, not a licence: nothing stops a contact being logged
/// either side of a boundary. Bands where N1MM's own numbers contradict
/// themselves — 60M, and everything above 2M — have no entry, and nothing is
/// painted for them.
public sealed class BandPlan
{
private static readonly Dictionary<string, double> DefaultCwEndsAtKilohertz = new()
{
["160M"] = 1_840,
["80M"] = 3_600,
["40M"] = 7_125,
["30M"] = 10_150,
["20M"] = 14_150,
["17M"] = 18_110,
["15M"] = 21_200,
["12M"] = 24_930,
["10M"] = 28_300,
["6M"] = 50_100,
["4M"] = 70_100,
["2M"] = 144_100,
};
public static readonly BandPlan Default = new(DefaultCwEndsAtKilohertz
.Select(pair => new BandSegments(
Bands.Named(pair.Key)!,
Frequency.FromKilohertz(pair.Value))));
private readonly Dictionary<string, BandSegments> byBand;
public BandPlan(IEnumerable<BandSegments> segments)
{
byBand = segments.ToDictionary(s => s.Band.Name, StringComparer.OrdinalIgnoreCase);
All = [.. byBand.Values.OrderBy(s => s.Band.Low.Hertz)];
}
/// Low band first, which is the order the editor lists them in.
public IReadOnlyList<BandSegments> All { get; }
public BandSegments? For(Band band) =>
byBand.TryGetValue(band.Name, out BandSegments? segments) ? segments : null;
/// The same plan with one band's boundaries changed. A band the plan does
/// not hold is added.
public BandPlan With(BandSegments segments) =>
new(All.Where(s => s.Band != segments.Band).Append(segments));
}

View File

@@ -0,0 +1,61 @@
using Nonemm.Core;
namespace Nonemm.Core.Tests;
public class BandPlanTests
{
[Fact]
public void TwentyMetresSplitsAtTheCwEdge()
{
BandSegments segments = Assert.IsType<BandSegments>(BandPlan.Default.For(Bands.Band20M));
Assert.Equal(Bands.Band20M.Low, segments.CwLow);
Assert.Equal(Frequency.FromKilohertz(14_150), segments.CwHigh);
Assert.Equal(Frequency.FromKilohertz(14_150), segments.PhoneLow);
Assert.Equal(Bands.Band20M.High, segments.PhoneHigh);
Assert.True(segments.HasPhone);
Assert.False(segments.HasDigital);
}
[Fact]
public void ThirtyMetresIsAllCw()
{
BandSegments segments = Assert.IsType<BandSegments>(BandPlan.Default.For(Bands.Band30M));
Assert.Equal(Bands.Band30M.High, segments.CwHigh);
Assert.False(segments.HasPhone);
}
/// Nothing is painted for a band with no boundary.
[Theory]
[InlineData("60M")]
[InlineData("70CM")]
[InlineData("630M")]
public void ABandWithNoBoundaryHasNoSegments(string name) =>
Assert.Null(BandPlan.Default.For(Bands.Named(name)!));
[Fact]
public void ChangingOneBandLeavesTheOthersAlone()
{
BandPlan changed = BandPlan.Default.With(
new BandSegments(Bands.Band20M, Frequency.FromKilohertz(14_100)));
Assert.Equal(Frequency.FromKilohertz(14_100), changed.For(Bands.Band20M)!.CwHigh);
Assert.Equal(
BandPlan.Default.For(Bands.Band40M)!.CwHigh,
changed.For(Bands.Band40M)!.CwHigh);
Assert.Equal(BandPlan.Default.All.Count, changed.All.Count);
}
[Fact]
public void ADigitalBlockIsThereOnceItHasWidth()
{
BandSegments segments = new(
Bands.Band20M,
Frequency.FromKilohertz(14_150),
Frequency.FromKilohertz(14_070),
Frequency.FromKilohertz(14_099));
Assert.True(segments.HasDigital);
}
}