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:
@@ -663,11 +663,47 @@ rather than guessing.
|
||||
|
||||
### Available mults and Qs
|
||||
|
||||
**View → Available Mults and Qs** lists what is spotted and not worked yet, a
|
||||
column per band, multipliers first and then the stations that are only points.
|
||||
Clicking one puts the radio there with the call in the entry window, the same as
|
||||
clicking a spot on the bandmap. **Mults only** hides everything that brings no
|
||||
multiplier.
|
||||
**View → Available Mults and Qs** lists what is spotted and not worked yet. The
|
||||
top shows a table with the multipliers, the Qs and the total on each band.
|
||||
Under it is one line per station. Click a header to sort on that column, and
|
||||
shift-click a second header to add a second sort key. The line is coloured the
|
||||
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
|
||||
the multiplier follows from the callsign — a DXCC entity — or where the call
|
||||
|
||||
+44
-1
@@ -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
|
||||
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
|
||||
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,
|
||||
|
||||
@@ -85,6 +85,28 @@ public sealed record Settings
|
||||
|
||||
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
|
||||
/// to find the station by ear. N1MM calls it randomising.
|
||||
public bool RandomizeSpots { get; init; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,37 @@
|
||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Nonemm.App.Windows"
|
||||
xmlns:theming="using:Nonemm.App.Theming"
|
||||
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">
|
||||
<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 & Modes" />
|
||||
<TextBlock Name="StatusText" FontSize="11" Opacity="0.7" VerticalAlignment="Center" />
|
||||
<TextBlock Name="NoticeText" FontSize="11" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<ScrollViewer>
|
||||
<Grid Name="Columns" />
|
||||
</ScrollViewer>
|
||||
<Grid Name="Summary" DockPanel.Dock="Top" Margin="0,0,0,6" HorizontalAlignment="Left" />
|
||||
<DataGrid Name="Spots" IsReadOnly="True" GridLinesVisibility="Horizontal" CanUserSortColumns="True"
|
||||
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>
|
||||
</local:RefreshableWindow>
|
||||
|
||||
@@ -1,117 +1,686 @@
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.App.Theming;
|
||||
using System.ComponentModel;
|
||||
using Avalonia;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Session;
|
||||
using Nonemm.Spotting;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// What is left to work, a column per band: the multipliers at the top of each
|
||||
/// column, then the stations that are only points. Clicking one puts the radio
|
||||
/// there with the call in the entry window, the same as clicking a spot on the
|
||||
/// bandmap.
|
||||
/// The spotted stations that are not worked yet, as N1MM lays them out: a
|
||||
/// summary of how many multipliers and Qs each band holds, and under it one
|
||||
/// line per station that can be sorted on any column. Clicking a line puts the
|
||||
/// radio there with the call in the entry window, the same as clicking a spot
|
||||
/// on the bandmap.
|
||||
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 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)
|
||||
{
|
||||
this.session = session;
|
||||
this.tune = tune;
|
||||
InitializeComponent();
|
||||
BuildColumns();
|
||||
ApplyColumns();
|
||||
ApplyFontSize();
|
||||
_ = new FontZoom(this, new Settings().AvailableFontSize, () => session.Settings.AvailableFontSize, size =>
|
||||
{
|
||||
session.Save(session.Settings with { AvailableFontSize = size });
|
||||
ApplyFontSize();
|
||||
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);
|
||||
timer.Tick += (_, _) => OnTick();
|
||||
timer.Start();
|
||||
Closed += (_, _) => timer.Stop();
|
||||
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()
|
||||
{
|
||||
Columns.Children.Clear();
|
||||
Columns.ColumnDefinitions.Clear();
|
||||
refreshPending = false;
|
||||
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)
|
||||
{
|
||||
StatusText.Text = "no contest is open";
|
||||
Spots.ItemsSource = Array.Empty<AvailableRow>();
|
||||
shownKey = "";
|
||||
return;
|
||||
}
|
||||
|
||||
session.Bandmap.DropOlderThan(DateTime.UtcNow);
|
||||
bool multsOnly = MultsOnlyBox.IsChecked == true;
|
||||
List<AvailableStation> stations = session.Available.All()
|
||||
.Where(s => !multsOnly || s.IsMultiplier)
|
||||
.ToList();
|
||||
List<Band> bands = stations
|
||||
.Select(s => s.Band)
|
||||
.Distinct()
|
||||
.OrderBy(b => b.Low.Hertz)
|
||||
.ToList();
|
||||
|
||||
int at = 0;
|
||||
foreach (Band band in bands)
|
||||
DateTime now = DateTime.UtcNow;
|
||||
session.Bandmap.DropOlderThan(now);
|
||||
IReadOnlyList<AvailableStation> all = session.Available.All();
|
||||
if (Summary.IsVisible)
|
||||
{
|
||||
Columns.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
|
||||
Control column = BuildColumn(band, stations.Where(s => s.Band == band).ToList());
|
||||
Grid.SetColumn(column, at++);
|
||||
Columns.Children.Add(column);
|
||||
BuildSummary(all);
|
||||
}
|
||||
int mults = all.Count(s => s.IsMultiplier);
|
||||
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);
|
||||
StatusText.Text = stations.Count == 0
|
||||
? "nothing spotted that is not worked"
|
||||
: $"{stations.Count} to work · {mults} multipliers";
|
||||
IReadOnlyList<string> preferred = session.Settings.SpotFilter.PreferredSpotters;
|
||||
Frequency? tuned = session.Position?.Frequency;
|
||||
List<AvailableRow> rows = all
|
||||
.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) };
|
||||
panel.Children.Add(new TextBlock
|
||||
IReadOnlyList<string> bands = session.Settings.AvailableBands;
|
||||
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}",
|
||||
FontSize = session.Settings.AvailableFontSize - 2,
|
||||
Opacity = 0.7,
|
||||
Margin = new Avalonia.Thickness(0, 0, 0, 4),
|
||||
});
|
||||
foreach (AvailableStation station in stations)
|
||||
{
|
||||
panel.Children.Add(Row(station));
|
||||
return;
|
||||
}
|
||||
shownKey = key;
|
||||
DataGridCollectionView view = new(rows);
|
||||
if (Spots.ItemsSource is DataGridCollectionView old)
|
||||
{
|
||||
foreach (DataGridSortDescription sort in old.SortDescriptions)
|
||||
{
|
||||
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),
|
||||
FontFamily = Fonts.Mono,
|
||||
return;
|
||||
}
|
||||
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,
|
||||
Foreground = Verdicts.Colour(station.Verdict),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Cursor = new Cursor(StandardCursorType.Hand),
|
||||
Margin = new Thickness(6, 0),
|
||||
HorizontalAlignment = column == 0 ? HorizontalAlignment.Left : HorizontalAlignment.Right,
|
||||
Foreground = highlight ? Verdicts.NewMultiplier : null,
|
||||
FontWeight = highlight ? FontWeight.Bold : FontWeight.Normal,
|
||||
};
|
||||
ToolTip.SetTip(text, Tip(station));
|
||||
text.PointerPressed += (_, _) => tune(station.Spot.Frequency, station.Spot.Call.Text);
|
||||
return text;
|
||||
if (click is not null)
|
||||
{
|
||||
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}";
|
||||
return station.IsMultiplier ? $"{call} · {station.MultiplierText}" : call;
|
||||
if (!e.KeyModifiers.HasFlag(KeyModifiers.Shift) || picked.Count == 0)
|
||||
{
|
||||
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}" : "";
|
||||
return $"{Verdicts.Describe(station.Verdict)}{who}";
|
||||
pausedUntil = DateTime.MinValue;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
</MenuItem>
|
||||
</MenuItem>
|
||||
<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="Call Stack" Click="OnShowCallStack" />
|
||||
<MenuItem Header="Check" Click="OnShowCheck" />
|
||||
|
||||
@@ -504,6 +504,11 @@ public sealed partial class EntryWindow : Window
|
||||
e.Handled = true;
|
||||
OnQuickEditBack(this, new RoutedEventArgs());
|
||||
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):
|
||||
e.Handled = true;
|
||||
OnQuickEditForward(this, new RoutedEventArgs());
|
||||
@@ -656,10 +661,12 @@ public sealed partial class EntryWindow : Window
|
||||
{
|
||||
return;
|
||||
}
|
||||
string loggedCall;
|
||||
try
|
||||
{
|
||||
Qso logged = Logging.LogContact();
|
||||
Status($"logged {logged.Call} for {logged.Points} points");
|
||||
loggedCall = logged.Call.Text;
|
||||
}
|
||||
catch (Exception e) when (e is InvalidOperationException or IOException)
|
||||
{
|
||||
@@ -669,6 +676,10 @@ public sealed partial class EntryWindow : Window
|
||||
ResetEsm();
|
||||
SyncBoxes();
|
||||
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
|
||||
|
||||
@@ -5,8 +5,15 @@ using Nonemm.Spotting;
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// 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 bool IsMultiplier => Verdict.NewMultipliers.Count > 0;
|
||||
@@ -52,7 +59,12 @@ public sealed class AvailableStations
|
||||
Verdict verdict = position.Log.Judge(Candidate(spot));
|
||||
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
|
||||
|
||||
@@ -37,6 +37,30 @@ public sealed record StationPath(
|
||||
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) =>
|
||||
Place(session.Me.GridSquare) ?? Place(session.Session.Countries?.Find(session.Me.Callsign));
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
lock (guard)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
/// One station on the bandmap.
|
||||
public sealed record Spot(
|
||||
public sealed partial record Spot(
|
||||
Callsign Call,
|
||||
Frequency Frequency,
|
||||
DateTime AtUtc,
|
||||
@@ -56,5 +57,19 @@ public sealed record Spot(
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -177,4 +177,14 @@ public class AvailableStationsTests
|
||||
AvailableStation station = Assert.Single(available.All());
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user