From 0a23815f381d349c80e31734e7fa0064d891841a Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sun, 25 Oct 2026 11:56:32 +0000 Subject: [PATCH] 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 --- README.md | 46 +- docs/unfinished.md | 45 +- src/Nonemm.App/Configuration/Settings.cs | 22 + src/Nonemm.App/Windows/AvailableRow.cs | 155 ++++ src/Nonemm.App/Windows/AvailableWindow.axaml | 32 +- .../Windows/AvailableWindow.axaml.cs | 685 ++++++++++++++++-- src/Nonemm.App/Windows/EntryWindow.axaml | 2 +- src/Nonemm.App/Windows/EntryWindow.axaml.cs | 11 + src/Nonemm.Session/AvailableStations.cs | 16 +- src/Nonemm.Session/StationPath.cs | 24 + src/Nonemm.Spotting/Bandmap.cs | 13 + src/Nonemm.Spotting/Spot.cs | 17 +- .../AvailableStationsTests.cs | 10 + tests/Nonemm.Spotting.Tests/BandmapTests.cs | 18 + tests/Nonemm.Spotting.Tests/SpotSnrTests.cs | 28 + 15 files changed, 1051 insertions(+), 73 deletions(-) create mode 100644 src/Nonemm.App/Windows/AvailableRow.cs create mode 100644 tests/Nonemm.Spotting.Tests/SpotSnrTests.cs diff --git a/README.md b/README.md index df5442c..d13deeb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/unfinished.md b/docs/unfinished.md index b447a12..67095c2 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -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, diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 52b2404..2899ea4 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -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 AvailableHiddenColumns { get; init; } = []; + + /// Band names the window is limited to. Empty is every band. + public IReadOnlyList AvailableBands { get; init; } = []; + + /// `CW`, `PHONE` or `DIGITAL`. Empty is every mode. + public IReadOnlyList AvailableModes { get; init; } = []; + + /// Band names in the summary table. Empty is the contest's bands. + public IReadOnlyList 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; } diff --git a/src/Nonemm.App/Windows/AvailableRow.cs b/src/Nonemm.App/Windows/AvailableRow.cs new file mode 100644 index 0000000..a318e60 --- /dev/null +++ b/src/Nonemm.App/Windows/AvailableRow.cs @@ -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 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; + } +} diff --git a/src/Nonemm.App/Windows/AvailableWindow.axaml b/src/Nonemm.App/Windows/AvailableWindow.axaml index 387cf07..27a93b1 100644 --- a/src/Nonemm.App/Windows/AvailableWindow.axaml +++ b/src/Nonemm.App/Windows/AvailableWindow.axaml @@ -1,15 +1,37 @@ + Title="Available Mults and Qs" Width="780" Height="440"> - +