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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
20
src/Nonemm.App/Dialogs/SubBandsDialog.axaml
Normal file
20
src/Nonemm.App/Dialogs/SubBandsDialog.axaml
Normal 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>
|
||||
125
src/Nonemm.App/Dialogs/SubBandsDialog.axaml.cs
Normal file
125
src/Nonemm.App/Dialogs/SubBandsDialog.axaml.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" />
|
||||
|
||||
75
src/Nonemm.Core/BandPlan.cs
Normal file
75
src/Nonemm.Core/BandPlan.cs
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user