Lay out Available Mults and Qs like N1MM's window

The window is now a summary table of mults, Qs and totals per band over a
sortable spot grid with Call, Freq, Mode, Dir, Mlt, SN, TS, Spotter, Grid,
Sun, Pts and a preferred-spotter column. Columns can be hidden, headers
sorted, and the list stops re-sorting for six seconds under the mouse.

Adds the Mults / Mults & Qs button, a Bands & Modes filter, delete and
blacklist from the right-click menu, Alt+A stepping, and QSY to the top spot
after logging. Spot gains Snr and IsSkimmer, Bandmap gains Remaining, and
AvailableStation carries the path to the station.

The menu item no longer has apostrophes. SO2R, SO2V and the other missing
pieces are listed in docs/unfinished.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 11:56:32 +00:00
co-authored by Claude Sonnet 5.5
parent 5cf8453534
commit 0a23815f38
15 changed files with 1051 additions and 73 deletions
+41 -5
View File
@@ -663,11 +663,47 @@ rather than guessing.
### Available mults and Qs ### Available mults and Qs
**View → Available Mults and Qs** lists what is spotted and not worked yet, a **View → Available Mults and Qs** lists what is spotted and not worked yet. The
column per band, multipliers first and then the stations that are only points. top shows a table with the multipliers, the Qs and the total on each band.
Clicking one puts the radio there with the call in the entry window, the same as Under it is one line per station. Click a header to sort on that column, and
clicking a spot on the bandmap. **Mults only** hides everything that brings no shift-click a second header to add a second sort key. The line is coloured the
multiplier. same as in the entry window, and the one the radio is on is drawn in reverse.
| Column | Shows |
| --- | --- |
| Call, Freq, Mode | the spot; `»` after the frequency marks a split |
| Dir | beam heading from the grid in call history, or the country file |
| Mlt | how many multipliers the station would bring |
| SN | decibels from a Skimmer spot's comment |
| TS | spot time in UTC; `◴` when the spot is within 3 minutes of dropping off the bandmap |
| Spotter | `(#)` after a Skimmer or beacon spotter |
| Grid | grid square from call history |
| Sun | `▲` or `▼` and the minutes to (+) or since (-) sunrise or sunset at the station, within one hour |
| Pts | points the contact would bring |
| ★ | the spotter is in the telnet window's preferred spotters |
Right-click a header to show or hide a column, or to resize the columns to their
contents. Drag a header to move a column.
- Click a line to put the radio there with the call in the entry window. Click
with the middle button, or right-click ▸ **Delete Spot**, to remove the spot
from the bandmap. **Blacklist callsign** and **Blacklist spotter** add the
name to the telnet window's blocked lists and remove its spots.
- Alt+A picks the next line and tunes to it, Shift+Alt+A the previous one and
Ctrl+Alt+A the first. Nothing happens until a line is picked.
- While the mouse is over the list the list stops re-sorting for six seconds, so
the line being aimed at stays put. The window says "Scrolling Pausing".
- Click a band in the table to show only that band. Shift-click adds more bands.
Click a number in the Mults row to show that band's multipliers, or in the Qs
row to show its multipliers and Qs. Click Mults, Qs or Total at the left to
show every band.
- The **Mults** / **Mults & Qs** button shows what the list holds now, and a
click switches it. **Bands & Modes** limits the list to some bands and modes
without touching the telnet filters. Both are kept between runs.
- The right-click menu also holds **Summary Panel Band Selection**, **Show
Summary Panel** and **Help**. With the list sorted on TS, newest first,
**QSY to top spot after logging QSO** tunes to the newest spot after each
contact logged while searching and pouncing.
A spot carries no exchange, so a station is only known to be a multiplier where A spot carries no exchange, so a station is only known to be a multiplier where
the multiplier follows from the callsign — a DXCC entity — or where the call the multiplier follows from the callsign — a DXCC entity — or where the call
+44 -1
View File
@@ -789,7 +789,50 @@ 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 start, and its 70CM CW top is above the band. Config ▸ Sub bands cannot add a
band that has no default. band that has no default.
**Available mults and Qs is only as good as the bandmap.** It lists what is **Available Mults and Qs differs from N1MM's window.** Reference:
https://n1mmwp.hamdocs.com/manual-windows/available-mults-and-qs-window/
Needs SO2R, which has two radio positions and two entry windows already:
- the window judges and tunes with the first radio's position only;
- Focus Radio, Non-Focus Radio and Only Radio 1/2 filters;
- Shift-click sends a spot to the other radio;
- Spot to Associated Radio, with the warning when the window opens;
- Focus Follows Spot, which moves focus to the radio the spot went to;
- a second Available window, one per radio;
- Enable Right-click QSY on Spot, which sends the spot to the non-focus radio;
- Alt+B, Shift+Alt+B and Ctrl+Alt+B for the second window.
Needs SO2V, which is not implemented at all (the program has split transmit
through rigctld `S 1 VFOB` and a bandmap bar for the second radio, nothing else):
- reading the sub-receiver or VFO B from the rig, and a position for it;
- Basic and Advanced SO2V VFO control, where a spot goes to the VFO already on
its band, otherwise the active VFO (Basic always uses VFO A);
- the Basic or Advanced label in the title bar.
Needs something the program does not have:
- the ♥ column, because there is no Special Calls list in the telnet window;
- the heard column, because there is no spectrum display;
- Turn Rotor, because there is no rotator control;
- the `(!)` marker for a local Skimmer, because a spot does not say whether the
Skimmer is ours.
Smaller differences:
- the TS header does not change colour while QSY after logging is on;
- the last picked line has the grid's own selection, not N1MM's light green
frame;
- a left click does both the QSY and the call load, where N1MM loads the call
only on a double click;
- the title bar shows the Qs, Mults and total counts, where N1MM also shows the
VFO or focus mode;
- there is no option to turn off the Pts column's calculation to save CPU;
- tooltips are always on, not tied to a setting;
- the column order is not kept between runs, only which columns are hidden.
**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 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 shows the mults a contest has and nobody has worked, which needs a list of every
multiplier the contest counts. Only the section and state lists are held here, multiplier the contest counts. Only the section and state lists are held here,
+22
View File
@@ -85,6 +85,28 @@ public sealed record Settings
public int AvailableFontSize { get; init; } = 13; public int AvailableFontSize { get; init; } = 13;
/// The Available Mults and Qs window lists only the stations that bring a
/// multiplier. Off lists the stations that are only points as well.
public bool AvailableMultsOnly { get; init; }
/// Headers of the columns the window hides. Call cannot be hidden.
public IReadOnlyList<string> AvailableHiddenColumns { get; init; } = [];
/// Band names the window is limited to. Empty is every band.
public IReadOnlyList<string> AvailableBands { get; init; } = [];
/// `CW`, `PHONE` or `DIGITAL`. Empty is every mode.
public IReadOnlyList<string> AvailableModes { get; init; } = [];
/// Band names in the summary table. Empty is the contest's bands.
public IReadOnlyList<string> AvailableSummaryBands { get; init; } = [];
public bool AvailableShowsSummary { get; init; } = true;
/// After a contact is logged while searching and pounce, the radio goes to
/// the newest spot. Needs the list sorted on TS, newest first.
public bool AvailableQsyAfterLogging { get; init; }
/// Moves each incoming CW spot by a few tens of hertz, so the operator has /// Moves each incoming CW spot by a few tens of hertz, so the operator has
/// to find the station by ear. N1MM calls it randomising. /// to find the station by ear. N1MM calls it randomising.
public bool RandomizeSpots { get; init; } public bool RandomizeSpots { get; init; }
+155
View File
@@ -0,0 +1,155 @@
using System.Globalization;
using Avalonia.Media;
using Nonemm.Core;
using Nonemm.Session;
namespace Nonemm.App.Windows;
/// One line of the available window. The text properties are what the cells
/// show; the typed ones are what the columns sort on, so 9 comes before 10.
public sealed class AvailableRow
{
/// A spot this close to dropping off the bandmap gets a clock beside its
/// time.
private static readonly TimeSpan ExpiringWithin = TimeSpan.FromMinutes(3);
/// The sun marker shows from this long before sunrise or sunset until this
/// long after.
private static readonly TimeSpan SunWindow = TimeSpan.FromHours(1);
private readonly DateTime nowUtc;
public AvailableRow(
AvailableStation station,
string mode,
DateTime nowUtc,
TimeSpan? remaining,
bool isPreferred,
bool isTuned)
{
Station = station;
Mode = mode;
this.nowUtc = nowUtc;
Remaining = remaining;
IsPreferred = isPreferred;
IsTuned = isTuned;
SunMinutes = SunMinutesFor(station, nowUtc);
}
public AvailableStation Station { get; }
public TimeSpan? Remaining { get; }
public bool IsPreferred { get; }
/// The radio is on this spot, which the window paints in reverse.
public bool IsTuned { get; }
public string Call => Station.Spot.Call.Text;
public double Kilohertz => Station.Spot.Frequency.Kilohertz;
public string Frequency => Kilohertz.ToString("0.0", CultureInfo.InvariantCulture) + (Station.Spot.IsSplit ? " »" : "");
public string Mode { get; }
public int? Heading => Station.Heading is { } heading ? (int)Math.Round(heading) % 360 : null;
public string Direction => Heading?.ToString(CultureInfo.InvariantCulture) ?? "";
public int Multipliers => Station.NewMultipliers.Count;
public int? Snr => Station.Spot.Snr;
public string Noise => Snr?.ToString(CultureInfo.InvariantCulture) ?? "";
public DateTime AtUtc => Station.Spot.AtUtc;
public bool IsExpiring => Remaining is { } left && left <= ExpiringWithin;
public string Time => AtUtc.ToString("HH:mm", CultureInfo.InvariantCulture) + (IsExpiring ? " ◴" : "");
public string Spotter => Station.Spot.Spotter + (Station.Spot.IsSkimmer ? " (#)" : "");
public string Grid => Station.Grid;
/// Minutes to the next sunrise or sunset at the station, negative once it
/// has passed, or `int.MaxValue` when the sun is not within the hour.
public int SunMinutes { get; }
public string Sun => SunMinutes == int.MaxValue ? "" : SunText;
public int Points => Station.Verdict.Points;
public string Star => IsPreferred ? "★" : "";
public IBrush Colour => Verdicts.Colour(Station.Verdict);
public IBrush Background => Verdicts.Background(Station.Verdict);
/// What a refresh compares to see whether the list changed.
public string Key =>
$"{Call}|{Station.Band.Name}|{Frequency}|{Multipliers}|{Points}|{Spotter}|{Time}|{Sun}|{Star}|{IsTuned}";
public string Tip
{
get
{
List<string> lines = [$"{Call} spotted by {Station.Spot.Spotter}", $"{(int)(nowUtc - AtUtc).TotalMinutes} min ago"];
if (Station.Spot.Comment.Length > 0)
{
lines.Add(Station.Spot.Comment);
}
if (Station.Spot.IsSplit)
{
lines.Add($"listening on {Station.Spot.Qsx.Kilohertz:0.0}");
}
if (Sun.Length > 0)
{
lines.Add(SunNote);
}
return string.Join('\n', lines);
}
}
private string SunText
{
get
{
(bool isRise, int minutes) = Nearest(Station, nowUtc)!.Value;
return $"{(isRise ? "▲" : "▼")}{minutes:+0;-0;0}";
}
}
private string SunNote
{
get
{
(bool isRise, int minutes) = Nearest(Station, nowUtc)!.Value;
string when = minutes >= 0 ? $"in {minutes} min" : $"{-minutes} min ago";
return $"{(isRise ? "sunrise" : "sunset")} at the station {when}";
}
}
private static int SunMinutesFor(AvailableStation station, DateTime nowUtc) =>
Nearest(station, nowUtc) is { } near ? near.Minutes : int.MaxValue;
/// The sunrise or sunset within the hour either side, whichever is nearer.
private static (bool IsRise, int Minutes)? Nearest(AvailableStation station, DateTime nowUtc)
{
(bool IsRise, int Minutes)? best = null;
foreach ((bool isRise, DateTime? at) in new[] { (true, station.Path?.Sunrise), (false, station.Path?.Sunset) })
{
if (at is not { } time || (time - nowUtc).Duration() > SunWindow)
{
continue;
}
int minutes = (int)Math.Round((time - nowUtc).TotalMinutes);
if (best is null || Math.Abs(minutes) < Math.Abs(best.Value.Minutes))
{
best = (isRise, minutes);
}
}
return best;
}
}
+27 -5
View File
@@ -1,15 +1,37 @@
<local:RefreshableWindow xmlns="https://github.com/avaloniaui" <local:RefreshableWindow xmlns="https://github.com/avaloniaui"
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"
xmlns:theming="using:Nonemm.App.Theming"
x:Class="Nonemm.App.Windows.AvailableWindow" x:Class="Nonemm.App.Windows.AvailableWindow"
Title="Available mults and Qs" Width="620" Height="340"> Title="Available Mults and Qs" Width="780" Height="440">
<DockPanel Margin="6"> <DockPanel Margin="6">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="0,0,0,6"> <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="0,0,0,6">
<CheckBox Name="MultsOnlyBox" Content="Mults only" /> <Button Name="ModeButton" MinWidth="90" ToolTip.Tip="Show only multipliers, or multipliers and Qs" />
<Button Name="FilterButton" Content="Bands &amp; Modes" />
<TextBlock Name="StatusText" FontSize="11" Opacity="0.7" VerticalAlignment="Center" /> <TextBlock Name="StatusText" FontSize="11" Opacity="0.7" VerticalAlignment="Center" />
<TextBlock Name="NoticeText" FontSize="11" VerticalAlignment="Center" />
</StackPanel> </StackPanel>
<ScrollViewer> <Grid Name="Summary" DockPanel.Dock="Top" Margin="0,0,0,6" HorizontalAlignment="Left" />
<Grid Name="Columns" /> <DataGrid Name="Spots" IsReadOnly="True" GridLinesVisibility="Horizontal" CanUserSortColumns="True"
</ScrollViewer> CanUserReorderColumns="True" CanUserResizeColumns="True" SelectionMode="Single"
FontFamily="{x:Static theming:Fonts.Mono}">
<DataGrid.Styles>
<Style Selector="DataGridCell TextBlock">
<Setter Property="FontFamily" Value="{x:Static theming:Fonts.Mono}" />
<Setter Property="FontSize" Value="{DynamicResource AvailableFontSize}" />
<Setter Property="Margin" Value="6,0" />
</Style>
<Style Selector="DataGridRow">
<Setter Property="MinHeight" Value="0" />
</Style>
<Style Selector="DataGridCell">
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="0,1" />
</Style>
<Style Selector="DataGridColumnHeader">
<Setter Property="MinHeight" Value="0" />
</Style>
</DataGrid.Styles>
</DataGrid>
</DockPanel> </DockPanel>
</local:RefreshableWindow> </local:RefreshableWindow>
+627 -58
View File
@@ -1,117 +1,686 @@
using Nonemm.App.Configuration; using System.ComponentModel;
using Nonemm.App.Theming; using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree;
using Nonemm.App.Configuration;
using Nonemm.Core; using Nonemm.Core;
using Nonemm.Session; using Nonemm.Session;
using Nonemm.Spotting;
namespace Nonemm.App.Windows; namespace Nonemm.App.Windows;
/// What is left to work, a column per band: the multipliers at the top of each /// The spotted stations that are not worked yet, as N1MM lays them out: a
/// column, then the stations that are only points. Clicking one puts the radio /// summary of how many multipliers and Qs each band holds, and under it one
/// there with the call in the entry window, the same as clicking a spot on the /// line per station that can be sorted on any column. Clicking a line puts the
/// bandmap. /// radio there with the call in the entry window, the same as clicking a spot
/// on the bandmap.
public sealed partial class AvailableWindow : RefreshableWindow public sealed partial class AvailableWindow : RefreshableWindow
{ {
private const string HelpUrl = "https://n1mmwp.hamdocs.com/manual-windows/available-mults-and-qs-window/";
/// The list stops re-sorting for this long after the mouse touches it, so
/// the line being aimed at does not move.
private static readonly TimeSpan PauseFor = TimeSpan.FromSeconds(6);
/// The list is rebuilt this often with nothing else to cause it, so the sun
/// times and the expiry clocks stay right.
private const int RefreshEveryTicks = 15;
private readonly AppSession session; private readonly AppSession session;
private readonly Action<Frequency, string> tune; private readonly Action<Frequency, string> tune;
private readonly DispatcherTimer timer = new() { Interval = TimeSpan.FromSeconds(1) };
/// The bands a click in the summary table limits the list to. Empty is
/// every band.
private readonly HashSet<Band> clicked = [];
private AvailableRow? menuRow;
private bool menuOnHeader;
private string shownKey = "";
private DateTime pausedUntil = DateTime.MinValue;
private bool refreshPending;
private int ticks;
public AvailableWindow(AppSession session, Action<Frequency, string> tune) public AvailableWindow(AppSession session, Action<Frequency, string> tune)
{ {
this.session = session; this.session = session;
this.tune = tune; this.tune = tune;
InitializeComponent(); InitializeComponent();
BuildColumns();
ApplyColumns();
ApplyFontSize();
_ = new FontZoom(this, new Settings().AvailableFontSize, () => session.Settings.AvailableFontSize, size => _ = new FontZoom(this, new Settings().AvailableFontSize, () => session.Settings.AvailableFontSize, size =>
{ {
session.Save(session.Settings with { AvailableFontSize = size }); session.Save(session.Settings with { AvailableFontSize = size });
ApplyFontSize();
Refresh(); Refresh();
}); });
MultsOnlyBox.IsCheckedChanged += (_, _) => Refresh(); ModeButton.Click += (_, _) => SetMultsOnly(!session.Settings.AvailableMultsOnly);
FilterButton.Click += (_, _) => OpenFilterMenu();
Spots.AddHandler(PointerPressedEvent, OnSpotPressed, RoutingStrategies.Tunnel);
Spots.PointerMoved += (_, _) => Pause();
Spots.PointerWheelChanged += (_, _) => Pause();
Spots.PointerExited += (_, _) => Resume();
Spots.LoadingRow += (_, e) => Paint(e.Row);
Spots.ContextMenu = BuildMenu();
session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh); session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
timer.Tick += (_, _) => OnTick();
timer.Start();
Closed += (_, _) => timer.Stop();
Refresh(); Refresh();
} }
private void ApplyFontSize()
{
Resources["AvailableFontSize"] = (double)session.Settings.AvailableFontSize;
Spots.FontSize = session.Settings.AvailableFontSize;
}
// ---- paused sorting
private bool IsPaused => DateTime.UtcNow < pausedUntil;
private void Pause()
{
pausedUntil = DateTime.UtcNow + PauseFor;
ShowNotice();
}
private void Resume()
{
pausedUntil = DateTime.MinValue;
ShowNotice();
if (refreshPending)
{
Refresh();
}
}
private void ShowNotice() =>
NoticeText.Text = IsPaused
? $"Scrolling Pausing {Math.Max(1, (int)Math.Ceiling((pausedUntil - DateTime.UtcNow).TotalSeconds))} secs"
: "";
private void OnTick()
{
ShowNotice();
if (IsPaused)
{
return;
}
if (refreshPending || ++ticks >= RefreshEveryTicks)
{
Refresh();
}
}
// ---- columns
private void BuildColumns()
{
Column("Call", nameof(AvailableRow.Call), r => r.Call, 110);
Column("Freq", nameof(AvailableRow.Frequency), r => r.Kilohertz, 90);
Column("Mode", nameof(AvailableRow.Mode), r => r.Mode, 55);
Column("Dir", nameof(AvailableRow.Direction), r => r.Heading ?? -1, 50);
Column("Mlt", nameof(AvailableRow.Multipliers), r => r.Multipliers, 50);
Column("SN", nameof(AvailableRow.Noise), r => r.Snr ?? int.MinValue, 45);
Column("TS", nameof(AvailableRow.Time), r => r.AtUtc, 75);
Column("Spotter", nameof(AvailableRow.Spotter), r => r.Spotter, 110);
Column("Grid", nameof(AvailableRow.Grid), r => r.Grid, 65);
Column("Sun", nameof(AvailableRow.Sun), r => r.SunMinutes, 60);
Column("Pts", nameof(AvailableRow.Points), r => r.Points, 50);
Column("★", nameof(AvailableRow.Star), r => r.IsPreferred ? 1 : 0, 35);
}
/// The grid sorts on the typed value behind the cell, not on its text.
private void Column<T>(string header, string path, Func<AvailableRow, T> key, double width)
where T : IComparable<T>
{
Spots.Columns.Add(new DataGridTextColumn
{
Header = header,
Width = new DataGridLength(width),
Binding = new Binding(path) { Mode = BindingMode.OneWay },
CustomSortComparer = Comparer<AvailableRow>.Create((left, right) => key(left).CompareTo(key(right))),
});
}
private void ApplyColumns()
{
foreach (DataGridColumn column in Spots.Columns)
{
column.IsVisible = Header(column) == "Call" || !session.Settings.AvailableHiddenColumns.Contains(Header(column));
}
}
private static string Header(DataGridColumn column) => (string)column.Header!;
private void SetColumnShown(string header, bool shown)
{
List<string> hidden = [.. session.Settings.AvailableHiddenColumns.Where(h => h != header)];
if (!shown)
{
hidden.Add(header);
}
session.Save(session.Settings with { AvailableHiddenColumns = hidden });
ApplyColumns();
}
private void AutoResizeColumns()
{
foreach (DataGridColumn column in Spots.Columns)
{
column.Width = DataGridLength.SizeToCells;
}
}
/// The list is sorted newest first on TS, which is the only order the
/// QSY-after-logging option works in.
private bool IsNewestFirst =>
Spots.ItemsSource is DataGridCollectionView { SortDescriptions: [{ } first, ..] }
&& first is DataGridComparerSortDescription comparer
&& comparer.SourceComparer == Spots.Columns.First(c => Header(c) == "TS").CustomSortComparer
&& first.Direction == ListSortDirection.Descending;
// ---- refresh
public override void Refresh() public override void Refresh()
{ {
Columns.Children.Clear(); refreshPending = false;
Columns.ColumnDefinitions.Clear(); ticks = 0;
Summary.Children.Clear();
Summary.RowDefinitions.Clear();
Summary.ColumnDefinitions.Clear();
Summary.IsVisible = session.Settings.AvailableShowsSummary;
bool multsOnly = session.Settings.AvailableMultsOnly;
ModeButton.Content = multsOnly ? "Mults" : "Mults & Qs";
ModeButton.Foreground = multsOnly ? Verdicts.NewMultiplier : Verdicts.Worth;
if (session.Available is null) if (session.Available is null)
{ {
StatusText.Text = "no contest is open"; StatusText.Text = "no contest is open";
Spots.ItemsSource = Array.Empty<AvailableRow>();
shownKey = "";
return; return;
} }
session.Bandmap.DropOlderThan(DateTime.UtcNow); DateTime now = DateTime.UtcNow;
bool multsOnly = MultsOnlyBox.IsChecked == true; session.Bandmap.DropOlderThan(now);
List<AvailableStation> stations = session.Available.All() IReadOnlyList<AvailableStation> all = session.Available.All();
.Where(s => !multsOnly || s.IsMultiplier) if (Summary.IsVisible)
.ToList();
List<Band> bands = stations
.Select(s => s.Band)
.Distinct()
.OrderBy(b => b.Low.Hertz)
.ToList();
int at = 0;
foreach (Band band in bands)
{ {
Columns.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); BuildSummary(all);
Control column = BuildColumn(band, stations.Where(s => s.Band == band).ToList()); }
Grid.SetColumn(column, at++); int mults = all.Count(s => s.IsMultiplier);
Columns.Children.Add(column); Title = $"Available Mults and Qs — {all.Count - mults} Qs · {mults} Mults · {all.Count} total";
StatusText.Text = all.Count == 0
? "No workable spots: check the contest, the bands and the cluster filters"
: $"{mults} mults · {all.Count - mults} Qs · {all.Count} total";
if (IsPaused)
{
refreshPending = true;
return;
} }
int mults = stations.Count(s => s.IsMultiplier); IReadOnlyList<string> preferred = session.Settings.SpotFilter.PreferredSpotters;
StatusText.Text = stations.Count == 0 Frequency? tuned = session.Position?.Frequency;
? "nothing spotted that is not worked" List<AvailableRow> rows = all
: $"{stations.Count} to work · {mults} multipliers"; .Where(s => (!multsOnly || s.IsMultiplier) && IsShown(s))
.Select(s => new AvailableRow(
s,
ModeName(s.Spot.Frequency),
now,
session.Bandmap.Remaining(s.Spot),
preferred.Contains(s.Spot.Spotter, StringComparer.OrdinalIgnoreCase),
tuned is { } at && Math.Abs(at.Hertz - s.Spot.Frequency.Hertz) <= 100))
.ToList();
Show(rows);
} }
private Control BuildColumn(Band band, IReadOnlyList<AvailableStation> stations) private bool IsShown(AvailableStation station)
{ {
StackPanel panel = new() { Margin = new Avalonia.Thickness(0, 0, 8, 0) }; IReadOnlyList<string> bands = session.Settings.AvailableBands;
panel.Children.Add(new TextBlock IReadOnlyList<string> modes = session.Settings.AvailableModes;
ModeCategory? category = session.BandPlan.ModeAt(station.Spot.Frequency);
return (clicked.Count == 0 || clicked.Contains(station.Band))
&& (bands.Count == 0 || bands.Contains(station.Band.Name, StringComparer.OrdinalIgnoreCase))
&& (modes.Count == 0 || category is null || modes.Contains(ModeKey(category.Value), StringComparer.OrdinalIgnoreCase));
}
private static string ModeKey(ModeCategory category) => category switch
{
ModeCategory.Phone => "PHONE",
ModeCategory.Digital => "DIGITAL",
_ => "CW",
};
/// A refresh that changes nothing leaves the list alone, so the scroll
/// position and the picked line stay where the operator left them.
private void Show(List<AvailableRow> rows)
{
string key = string.Join(';', rows.Select(r => r.Key));
if (key == shownKey && Spots.ItemsSource is not null)
{ {
Text = $"{band.Name} {stations.Count}", return;
FontSize = session.Settings.AvailableFontSize - 2, }
Opacity = 0.7, shownKey = key;
Margin = new Avalonia.Thickness(0, 0, 0, 4), DataGridCollectionView view = new(rows);
}); if (Spots.ItemsSource is DataGridCollectionView old)
foreach (AvailableStation station in stations) {
{ foreach (DataGridSortDescription sort in old.SortDescriptions)
panel.Children.Add(Row(station)); {
view.SortDescriptions.Add(sort);
}
}
string? picked = (Spots.SelectedItem as AvailableRow)?.Call;
Spots.ItemsSource = view;
if (picked is not null && rows.FirstOrDefault(r => r.Call == picked) is { } again)
{
Spots.SelectedItem = again;
} }
return panel;
} }
private Control Row(AvailableStation station) private string ModeName(Frequency frequency) => session.BandPlan.ModeAt(frequency) switch
{ {
TextBlock text = new() ModeCategory.Cw => "CW",
ModeCategory.Phone => "SSB",
ModeCategory.Digital => "DIG",
_ => "",
};
/// A line is painted in the colour of what it would bring. The one the
/// radio is on is painted in reverse.
private static void Paint(DataGridRow line)
{
if (line.DataContext is not AvailableRow row)
{ {
Text = Label(station), return;
FontFamily = Fonts.Mono, }
line.Foreground = row.IsTuned ? row.Background : row.Colour;
line.Background = row.IsTuned ? row.Colour : Brushes.Transparent;
ToolTip.SetTip(line, row.Tip);
}
// ---- summary table
private IReadOnlyList<Band> SummaryBands(IReadOnlyList<AvailableStation> all)
{
IReadOnlyList<string> chosen = session.Settings.AvailableSummaryBands;
IEnumerable<Band> basis = chosen.Count > 0
? chosen.Select(Bands.Named).OfType<Band>()
: Bands.Contest;
return [.. basis.Concat(all.Select(s => s.Band)).Distinct().OrderBy(b => b.Low.Hertz)];
}
private void BuildSummary(IReadOnlyList<AvailableStation> all)
{
IReadOnlyList<Band> columns = SummaryBands(all);
Dictionary<Band, int> mults = columns.ToDictionary(b => b, b => all.Count(s => s.Band == b && s.IsMultiplier));
Dictionary<Band, int> qs = columns.ToDictionary(b => b, b => all.Count(s => s.Band == b && !s.IsMultiplier));
int mostMults = mults.Values.Max();
int mostQs = qs.Values.Max();
for (int row = 0; row < 4; row++)
{
Summary.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
}
Summary.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
foreach (Band _ in columns)
{
Summary.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
}
Label(0, 0, "", null);
Label(1, 0, "Mults", e => ShowBands([], null, e));
Label(2, 0, "Qs", e => ShowBands([], null, e));
Label(3, 0, "Total", e => ShowBands([], null, e));
for (int at = 0; at < columns.Count; at++)
{
Band band = columns[at];
int column = at + 1;
Label(0, column, band.Name.TrimEnd('M', 'm'), e => ShowBands([band], null, e), clicked.Contains(band));
Label(1, column, Count(mults[band]), e => ShowBands([band], true, e), mults[band] > 0 && mults[band] == mostMults);
Label(2, column, Count(qs[band]), e => ShowBands([band], false, e), qs[band] > 0 && qs[band] == mostQs);
Label(3, column, Count(mults[band] + qs[band]), e => ShowBands([band], null, e));
}
}
private static string Count(int count) => count == 0 ? "·" : count.ToString();
private void Label(int row, int column, string text, Action<PointerPressedEventArgs>? click, bool highlight = false)
{
TextBlock block = new()
{
Text = text,
FontFamily = Theming.Fonts.Mono,
FontSize = session.Settings.AvailableFontSize, FontSize = session.Settings.AvailableFontSize,
Foreground = Verdicts.Colour(station.Verdict), Margin = new Thickness(6, 0),
HorizontalAlignment = HorizontalAlignment.Left, HorizontalAlignment = column == 0 ? HorizontalAlignment.Left : HorizontalAlignment.Right,
Cursor = new Cursor(StandardCursorType.Hand), Foreground = highlight ? Verdicts.NewMultiplier : null,
FontWeight = highlight ? FontWeight.Bold : FontWeight.Normal,
}; };
ToolTip.SetTip(text, Tip(station)); if (click is not null)
text.PointerPressed += (_, _) => tune(station.Spot.Frequency, station.Spot.Call.Text); {
return text; block.Cursor = new Cursor(StandardCursorType.Hand);
block.PointerPressed += (_, e) => click(e);
}
Grid.SetRow(block, row);
Grid.SetColumn(block, column);
Summary.Children.Add(block);
} }
private static string Label(AvailableStation station) /// A click on a band shows that band alone and shift adds it to the ones
/// shown. `multsOnly` is null when the click does not say. No bands is all
/// of them.
private void ShowBands(IReadOnlyList<Band> picked, bool? multsOnly, PointerPressedEventArgs e)
{ {
string call = $"{station.Spot.Frequency.Kilohertz,9:0.0} {station.Spot.Call.Text}"; if (!e.KeyModifiers.HasFlag(KeyModifiers.Shift) || picked.Count == 0)
return station.IsMultiplier ? $"{call} · {station.MultiplierText}" : call; {
clicked.Clear();
}
foreach (Band band in picked)
{
clicked.Add(band);
}
pausedUntil = DateTime.MinValue;
if (multsOnly is { } only && only != session.Settings.AvailableMultsOnly)
{
session.Save(session.Settings with { AvailableMultsOnly = only });
}
Refresh();
} }
private static string Tip(AvailableStation station) private void SetMultsOnly(bool multsOnly)
{ {
string who = station.Spot.Spotter.Length > 0 ? $" de {station.Spot.Spotter}" : ""; pausedUntil = DateTime.MinValue;
return $"{Verdicts.Describe(station.Verdict)}{who}"; session.Save(session.Settings with { AvailableMultsOnly = multsOnly });
Refresh();
}
// ---- clicks and keys
private void OnSpotPressed(object? sender, PointerPressedEventArgs e)
{
Visual? source = e.Source as Visual;
menuOnHeader = source?.GetSelfAndVisualAncestors().OfType<DataGridColumnHeader>().Any() == true;
menuRow = source?.GetSelfAndVisualAncestors().OfType<DataGridRow>().FirstOrDefault()?.DataContext as AvailableRow;
Pause();
if (menuRow is null)
{
return;
}
PointerPointProperties point = e.GetCurrentPoint(Spots).Properties;
if (point.IsLeftButtonPressed)
{
Pick(menuRow);
}
else if (point.IsMiddleButtonPressed)
{
Delete(menuRow);
}
}
private void Pick(AvailableRow row)
{
Spots.SelectedItem = row;
tune(row.Station.Spot.Frequency, row.Call);
}
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (e.Key == Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Alt))
{
e.Handled = true;
Step(e.KeyModifiers);
}
}
/// Alt+A moves down the list and QSYs, Shift+Alt+A moves up and Ctrl+Alt+A
/// goes to the top. The list wraps. With no line picked nothing happens.
public void Step(KeyModifiers modifiers)
{
if (Spots.SelectedItem is not AvailableRow current || Spots.ItemsSource is not DataGridCollectionView view)
{
return;
}
List<AvailableRow> rows = [.. view.Cast<AvailableRow>()];
if (rows.Count == 0)
{
return;
}
int at = rows.FindIndex(r => r.Call == current.Call);
int next = modifiers.HasFlag(KeyModifiers.Control) ? 0
: modifiers.HasFlag(KeyModifiers.Shift) ? (at - 1 + rows.Count) % rows.Count
: (at + 1) % rows.Count;
Spots.ScrollIntoView(rows[next], null);
Pick(rows[next]);
}
/// Called after a contact is logged. When the option is on, the list is
/// sorted newest first and the operator is not running, the radio goes to
/// the top spot that is not the station just worked.
public void QsyToTopSpot(string loggedCall)
{
if (!session.Settings.AvailableQsyAfterLogging || !IsNewestFirst)
{
return;
}
pausedUntil = DateTime.MinValue;
Refresh();
if (Spots.ItemsSource is not DataGridCollectionView view)
{
return;
}
AvailableRow? top = view.Cast<AvailableRow>().FirstOrDefault(r => r.Call != loggedCall);
if (top is not null)
{
Pick(top);
}
}
// ---- menus
private ContextMenu BuildMenu()
{
ContextMenu menu = new();
menu.Opening += (_, _) => menu.ItemsSource = menuOnHeader ? HeaderItems() : SpotItems();
return menu;
}
private List<Control> HeaderItems()
{
List<Control> items = [];
foreach (DataGridColumn column in Spots.Columns.Where(c => Header(c) != "Call"))
{
string header = Header(column);
MenuItem item = new()
{
Header = header == "★" ? "★ preferred spotter" : header,
ToggleType = MenuItemToggleType.CheckBox,
IsChecked = column.IsVisible,
StaysOpenOnClick = true,
};
item.Click += (_, _) => SetColumnShown(header, !column.IsVisible);
items.Add(item);
}
items.Add(new Separator());
items.Add(Action("Auto Resize Columns", AutoResizeColumns));
return items;
}
private List<Control> SpotItems()
{
AvailableRow? row = menuRow;
MenuItem delete = Action("Delete Spot", () => Delete(row!));
MenuItem callBlacklist = Action("Blacklist callsign", () => Blacklist(row!, spotter: false));
MenuItem spotterBlacklist = Action("Blacklist spotter", () => Blacklist(row!, spotter: true));
delete.IsEnabled = callBlacklist.IsEnabled = spotterBlacklist.IsEnabled = row is not null;
MenuItem qs = Check("Show Qs && Mults", !session.Settings.AvailableMultsOnly, () => SetMultsOnly(!session.Settings.AvailableMultsOnly));
MenuItem qsyAfter = Check(
"QSY to top spot after logging QSO",
session.Settings.AvailableQsyAfterLogging,
() => session.Save(session.Settings with { AvailableQsyAfterLogging = !session.Settings.AvailableQsyAfterLogging }));
qsyAfter.IsEnabled = IsNewestFirst;
MenuItem summaryShown = Check(
"Show Summary Panel",
session.Settings.AvailableShowsSummary,
() =>
{
session.Save(session.Settings with { AvailableShowsSummary = !session.Settings.AvailableShowsSummary });
Refresh();
});
return
[
delete,
callBlacklist,
spotterBlacklist,
new Separator(),
Action("Set Band && Mode Filter Options", OpenFilterMenu),
qs,
qsyAfter,
new Separator(),
SummaryBandMenu(),
Action("Auto Resize Columns", AutoResizeColumns),
summaryShown,
new Separator(),
Action("Help", OpenHelp),
];
}
private static MenuItem Action(string header, Action run)
{
MenuItem item = new() { Header = header };
item.Click += (_, _) => run();
return item;
}
private static MenuItem Check(string header, bool isChecked, Action run)
{
MenuItem item = new() { Header = header, ToggleType = MenuItemToggleType.CheckBox, IsChecked = isChecked };
item.Click += (_, _) => run();
return item;
}
/// Any mix of bands for the summary table, from the contest's own to the
/// microwave bands.
private MenuItem SummaryBandMenu()
{
IReadOnlyList<Band> shown = SummaryBands(session.Available?.All() ?? []);
List<Control> items = [];
foreach (Band band in Bands.All)
{
MenuItem item = new()
{
Header = band.Name,
ToggleType = MenuItemToggleType.CheckBox,
IsChecked = shown.Contains(band),
StaysOpenOnClick = true,
};
item.Click += (_, _) =>
{
session.Save(session.Settings with
{
AvailableSummaryBands =
[
.. shown.Where(b => b != band).Concat(shown.Contains(band) ? [] : [band]).Select(b => b.Name),
],
});
Refresh();
};
items.Add(item);
}
return new MenuItem { Header = "Summary Panel Band Selection", ItemsSource = items };
}
/// The bands and modes the list is limited to. It does not touch the
/// telnet window's filters.
private void OpenFilterMenu()
{
List<Control> items = [];
foreach (Band band in Bands.ContestWithWarc)
{
items.Add(FilterItem(band.Name, session.Settings.AvailableBands.Contains(band.Name), on =>
session.Settings with { AvailableBands = Toggled(session.Settings.AvailableBands, band.Name, on) }));
}
items.Add(new Separator());
foreach ((string label, string key) in new[] { ("CW", "CW"), ("Phone", "PHONE"), ("Digital", "DIGITAL") })
{
items.Add(FilterItem(label, session.Settings.AvailableModes.Contains(key), on =>
session.Settings with { AvailableModes = Toggled(session.Settings.AvailableModes, key, on) }));
}
items.Add(new Separator());
items.Add(Action("All bands and modes", () =>
{
session.Save(session.Settings with { AvailableBands = [], AvailableModes = [] });
Refresh();
}));
new ContextMenu { ItemsSource = items }.Open(FilterButton);
}
private MenuItem FilterItem(string header, bool isChecked, Func<bool, Settings> change)
{
MenuItem item = new()
{
Header = header,
ToggleType = MenuItemToggleType.CheckBox,
IsChecked = isChecked,
StaysOpenOnClick = true,
};
item.Click += (_, _) =>
{
session.Save(change(item.IsChecked));
pausedUntil = DateTime.MinValue;
Refresh();
};
return item;
}
private static IReadOnlyList<string> Toggled(IReadOnlyList<string> names, string name, bool on) =>
[.. names.Where(n => n != name).Concat(on ? [name] : [])];
private void OpenHelp() => _ = TopLevel.GetTopLevel(this)?.Launcher.LaunchUriAsync(new Uri(HelpUrl));
// ---- changing the bandmap
private void Delete(AvailableRow row)
{
pausedUntil = DateTime.MinValue;
session.Bandmap.Remove(row.Station.Spot.Call, row.Station.Band);
}
/// Adds the call, or the spotter, to the telnet window's blocked lists and
/// takes what it already put on the map off.
private void Blacklist(AvailableRow row, bool spotter)
{
StoredSpotFilter filter = session.Settings.SpotFilter;
string name = spotter ? row.Station.Spot.Spotter : row.Call;
if (name.Length == 0)
{
return;
}
session.Save(session.Settings with
{
SpotFilter = spotter
? filter with { BlockedSpotters = [.. filter.BlockedSpotters, name] }
: filter with { BlockedCalls = [.. filter.BlockedCalls, name] },
});
pausedUntil = DateTime.MinValue;
IEnumerable<Spot> doomed = session.Bandmap.All().Where(s =>
spotter ? s.Spotter.Equals(name, StringComparison.OrdinalIgnoreCase) : s.Call.Text.Equals(name, StringComparison.OrdinalIgnoreCase));
foreach (Spot spot in doomed.ToList())
{
if (spot.Band is { } band)
{
session.Bandmap.Remove(spot.Call, band);
}
}
} }
} }
+1 -1
View File
@@ -120,7 +120,7 @@
</MenuItem> </MenuItem>
</MenuItem> </MenuItem>
<MenuItem Header="Wi_ndow"> <MenuItem Header="Wi_ndow">
<MenuItem Header="Available Mult's and Q's" Click="OnShowAvailable" /> <MenuItem Header="Available Mults and Qs" Click="OnShowAvailable" />
<MenuItem Header="_Bandmap" Click="OnShowBandmap" /> <MenuItem Header="_Bandmap" Click="OnShowBandmap" />
<MenuItem Header="Call Stack" Click="OnShowCallStack" /> <MenuItem Header="Call Stack" Click="OnShowCallStack" />
<MenuItem Header="Check" Click="OnShowCheck" /> <MenuItem Header="Check" Click="OnShowCheck" />
@@ -504,6 +504,11 @@ public sealed partial class EntryWindow : Window
e.Handled = true; e.Handled = true;
OnQuickEditBack(this, new RoutedEventArgs()); OnQuickEditBack(this, new RoutedEventArgs());
break; break;
case Key.A when e.KeyModifiers.HasFlag(KeyModifiers.Alt)
&& openWindows.GetValueOrDefault(typeof(AvailableWindow)) is AvailableWindow available:
e.Handled = true;
available.Step(e.KeyModifiers);
break;
case Key.A when e.KeyModifiers.HasFlag(KeyModifiers.Control): case Key.A when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true; e.Handled = true;
OnQuickEditForward(this, new RoutedEventArgs()); OnQuickEditForward(this, new RoutedEventArgs());
@@ -656,10 +661,12 @@ public sealed partial class EntryWindow : Window
{ {
return; return;
} }
string loggedCall;
try try
{ {
Qso logged = Logging.LogContact(); Qso logged = Logging.LogContact();
Status($"logged {logged.Call} for {logged.Points} points"); Status($"logged {logged.Call} for {logged.Points} points");
loggedCall = logged.Call.Text;
} }
catch (Exception e) when (e is InvalidOperationException or IOException) catch (Exception e) when (e is InvalidOperationException or IOException)
{ {
@@ -669,6 +676,10 @@ public sealed partial class EntryWindow : Window
ResetEsm(); ResetEsm();
SyncBoxes(); SyncBoxes();
boxes[0].Focus(); boxes[0].Focus();
if (!Logging.IsRunning && openWindows.GetValueOrDefault(typeof(AvailableWindow)) is AvailableWindow list)
{
list.QsyToTopSpot(loggedCall);
}
} }
/// N1MM's `{FORCELOG}`: the contact goes in the log with whatever is in the /// N1MM's `{FORCELOG}`: the contact goes in the log with whatever is in the
+14 -2
View File
@@ -5,8 +5,15 @@ using Nonemm.Spotting;
namespace Nonemm.Session; namespace Nonemm.Session;
/// One spotted station worth working, and what working it would bring. /// One spotted station worth working, and what working it would bring.
public sealed record AvailableStation(Spot Spot, Band Band, Verdict Verdict) public sealed record AvailableStation(
Spot Spot,
Band Band,
Verdict Verdict,
StationPath? Path = null,
string Grid = "")
{ {
public double? Heading => Path?.Heading;
public IReadOnlyList<Multiplier> NewMultipliers => Verdict.NewMultipliers; public IReadOnlyList<Multiplier> NewMultipliers => Verdict.NewMultipliers;
public bool IsMultiplier => Verdict.NewMultipliers.Count > 0; public bool IsMultiplier => Verdict.NewMultipliers.Count > 0;
@@ -52,7 +59,12 @@ public sealed class AvailableStations
Verdict verdict = position.Log.Judge(Candidate(spot)); Verdict verdict = position.Log.Judge(Candidate(spot));
if (!verdict.IsDupe) if (!verdict.IsDupe)
{ {
found.Add(new AvailableStation(spot, band, verdict)); found.Add(new AvailableStation(
spot,
band,
verdict,
StationPath.ToCall(position, spot.Call, DateTime.UtcNow),
position.Session.History.Find(spot.Call.Text)?.GridSquare ?? ""));
} }
} }
return found return found
+24
View File
@@ -37,6 +37,30 @@ public sealed record StationPath(
sun?.Set); sun?.Set);
} }
/// The path to a spotted station, from the same positions as `For` but for
/// a call that is not in the callsign box. Null when either end has no
/// position.
public static StationPath? ToCall(OperatingPosition session, Callsign call, DateTime nowUtc)
{
if (Mine(session) is not { } here)
{
return null;
}
(double Latitude, double Longitude)? there =
Place(session.Session.History.Find(call.Text)?.GridSquare ?? "")
?? Place(session.Session.Countries?.Find(call));
if (there is not { } target)
{
return null;
}
(DateTime Rise, DateTime Set)? sun = SunTimes.For(target.Latitude, target.Longitude, nowUtc);
return new StationPath(
GridSquare.Bearing(here.Latitude, here.Longitude, target.Latitude, target.Longitude),
GridSquare.DistanceKm(here.Latitude, here.Longitude, target.Latitude, target.Longitude),
sun?.Rise,
sun?.Set);
}
private static (double Latitude, double Longitude)? Mine(OperatingPosition session) => private static (double Latitude, double Longitude)? Mine(OperatingPosition session) =>
Place(session.Me.GridSquare) ?? Place(session.Session.Countries?.Find(session.Me.Callsign)); Place(session.Me.GridSquare) ?? Place(session.Session.Countries?.Find(session.Me.Callsign));
+13
View File
@@ -106,6 +106,19 @@ public sealed class Bandmap
} }
} }
/// How long the spot has left before it drops off the map, or null for a
/// spot that is not on it.
public TimeSpan? Remaining(Spot spot)
{
string key = Key(spot);
lock (guard)
{
return arrived.TryGetValue(key, out DateTime at)
? (LifetimeFor?.Invoke(spot) ?? Lifetime) - (clock() - at)
: null;
}
}
public IReadOnlyList<Spot> On(Band band) public IReadOnlyList<Spot> On(Band band)
{ {
lock (guard) lock (guard)
+16 -1
View File
@@ -1,9 +1,10 @@
using System.Text.RegularExpressions;
using Nonemm.Core; using Nonemm.Core;
namespace Nonemm.Spotting; namespace Nonemm.Spotting;
/// One station on the bandmap. /// One station on the bandmap.
public sealed record Spot( public sealed partial record Spot(
Callsign Call, Callsign Call,
Frequency Frequency, Frequency Frequency,
DateTime AtUtc, DateTime AtUtc,
@@ -56,5 +57,19 @@ public sealed record Spot(
public bool IsSplit => Qsx.Hertz > 0; public bool IsSplit => Qsx.Hertz > 0;
/// A reverse beacon or Skimmer spotter, which the network marks with a
/// `-#` on the call.
public bool IsSkimmer => Spotter.EndsWith("-#", StringComparison.Ordinal);
/// The signal-to-noise ratio in decibels that a Skimmer puts in the comment
/// (`CW 24 dB 28 WPM CQ`), or null when the comment has none.
public int? Snr =>
DecibelsPattern().Match(Comment) is { Success: true } match
? int.Parse(match.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture)
: null;
[GeneratedRegex(@"(?<![\w.])(\d{1,3})\s*dB\b", RegexOptions.IgnoreCase)]
private static partial Regex DecibelsPattern();
public Band? Band => Bands.ForFrequency(Frequency); public Band? Band => Bands.ForFrequency(Frequency);
} }
@@ -177,4 +177,14 @@ public class AvailableStationsTests
AvailableStation station = Assert.Single(available.All()); AvailableStation station = Assert.Single(available.All());
Assert.False(station.IsMultiplier); Assert.False(station.IsMultiplier);
} }
[Fact]
public void AStationInJapanIsToTheNortheastOfGermany()
{
(_, Bandmap bandmap, AvailableStations available) = Station();
bandmap.Add(At("JA1XYZ", On20));
AvailableStation station = Assert.Single(available.All());
Assert.InRange(station.Heading ?? -1, 1, 89);
}
} }
@@ -118,4 +118,22 @@ public class BandmapTests
map.Near(Frequency.FromKilohertz(14_010.2), Frequency.FromKilohertz(0.5))?.Call.Text); map.Near(Frequency.FromKilohertz(14_010.2), Frequency.FromKilohertz(0.5))?.Call.Text);
Assert.Null(map.Near(Frequency.FromKilohertz(14_030), Frequency.FromKilohertz(0.5))); Assert.Null(map.Near(Frequency.FromKilohertz(14_030), Frequency.FromKilohertz(0.5)));
} }
[Fact]
public void ASpotHasItsLifetimeLessTheTimeItHasBeenThere()
{
DateTime clock = Now;
Bandmap map = new(TimeSpan.FromMinutes(30), () => clock);
Spot spot = At("DL1ABC", 14_025);
map.Add(spot);
clock = Now.AddMinutes(12);
Assert.Equal(TimeSpan.FromMinutes(18), map.Remaining(spot));
}
[Fact]
public void ASpotThatIsNotOnTheMapHasNoTimeLeft()
{
Assert.Null(new Bandmap().Remaining(At("DL1ABC", 14_025)));
}
} }
@@ -0,0 +1,28 @@
using Nonemm.Core;
namespace Nonemm.Spotting.Tests;
public class SpotSnrTests
{
private static Spot Commented(string comment, string spotter = "OK1TEST") =>
new(Callsign.Parse("DL1ABC"), Frequency.FromKilohertz(14_025), DateTime.UtcNow, SpotSource.Cluster, spotter, comment);
[Fact]
public void ASkimmerCommentGivesTheDecibels()
{
Assert.Equal(24, Commented("CW 24 dB 28 WPM CQ").Snr);
}
[Fact]
public void ACommentWithNoDecibelsHasNoSnr()
{
Assert.Null(Commented("up 2").Snr);
}
[Fact]
public void ASpotterEndingInHashIsASkimmer()
{
Assert.True(Commented("", "DL1ABC-#").IsSkimmer);
Assert.False(Commented("", "DL1ABC").IsSkimmer);
}
}