Cut CPU use and move heavy work off the UI thread
Redraws: - Merge bursts of session and bandmap events into one redraw (Coalesced). Closed Bandmap and Available windows now unsubscribe. - Grayline and Voice Keyer no longer subscribe to the session twice. - Available summary, Score table and Log rows redraw only when their contents change. Telnet lines are added in batches. - Bandmap view reuses its layout, shaped text and brushes. Grayline draws the coastlines as one cached shape. Threads: - Available, Bandmap colours, Check and Statistics do their judging and counting on the thread pool and draw the result on the UI thread. - ContestLog takes a lock in Judge and in each method that changes the log, so a background Judge cannot read the indexes mid-edit. Refresh rate: - Statistics refreshes every 30 seconds and when the QSO count changes. - Grayline refreshes at most every 15 seconds on session events. Publish with ReadyToRun to cut startup JIT time. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -30,7 +30,7 @@ if [ -f "$archive" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
for project in Nonemm.App Nonemm.Recorder.App; do
|
for project in Nonemm.App Nonemm.Recorder.App; do
|
||||||
./build.sh publish "src/$project" -c Release -r "$rid" --self-contained -o "$out" || exit 1
|
./build.sh publish "src/$project" -c Release -r "$rid" --self-contained -p:PublishReadyToRun=true -o "$out" || exit 1
|
||||||
done
|
done
|
||||||
rm -f "$out"/*.pdb
|
rm -f "$out"/*.pdb
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
|||||||
private AvailableRow? menuRow;
|
private AvailableRow? menuRow;
|
||||||
private bool menuOnHeader;
|
private bool menuOnHeader;
|
||||||
private string shownKey = "";
|
private string shownKey = "";
|
||||||
|
private string summaryKey = "";
|
||||||
private DateTime pausedUntil = DateTime.MinValue;
|
private DateTime pausedUntil = DateTime.MinValue;
|
||||||
private bool refreshPending;
|
private bool refreshPending;
|
||||||
private int ticks;
|
private int ticks;
|
||||||
@@ -70,7 +71,10 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
|||||||
Spots.PointerExited += (_, _) => Resume();
|
Spots.PointerExited += (_, _) => Resume();
|
||||||
Spots.LoadingRow += (_, e) => Paint(e.Row);
|
Spots.LoadingRow += (_, e) => Paint(e.Row);
|
||||||
Spots.ContextMenu = BuildMenu();
|
Spots.ContextMenu = BuildMenu();
|
||||||
session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
|
Coalesced changed = new(Refresh);
|
||||||
|
EventHandler spotsChanged = (_, _) => changed.Request();
|
||||||
|
session.Bandmap.Changed += spotsChanged;
|
||||||
|
Closed += (_, _) => session.Bandmap.Changed -= spotsChanged;
|
||||||
timer.Tick += (_, _) => OnTick();
|
timer.Tick += (_, _) => OnTick();
|
||||||
timer.Start();
|
timer.Start();
|
||||||
Closed += (_, _) => timer.Stop();
|
Closed += (_, _) => timer.Stop();
|
||||||
@@ -191,64 +195,137 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
|||||||
|
|
||||||
// ---- refresh
|
// ---- refresh
|
||||||
|
|
||||||
|
private bool running;
|
||||||
|
private bool again;
|
||||||
|
|
||||||
|
/// Judging every spot and building the lines is done on other threads, so
|
||||||
|
/// a refresh never holds up a key press. A request that comes in while one
|
||||||
|
/// is running is answered by one more run afterwards, and the run it
|
||||||
|
/// replaces is not drawn.
|
||||||
public override void Refresh()
|
public override void Refresh()
|
||||||
{
|
{
|
||||||
refreshPending = false;
|
refreshPending = false;
|
||||||
ticks = 0;
|
ticks = 0;
|
||||||
Summary.Children.Clear();
|
if (running)
|
||||||
Summary.RowDefinitions.Clear();
|
{
|
||||||
Summary.ColumnDefinitions.Clear();
|
again = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ = RunAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunAsync()
|
||||||
|
{
|
||||||
|
running = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
do
|
||||||
|
{
|
||||||
|
again = false;
|
||||||
|
await Update();
|
||||||
|
}
|
||||||
|
while (again);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the other threads work out, so the UI thread only has to draw it.
|
||||||
|
private sealed record SummaryData(
|
||||||
|
IReadOnlyList<Band> Columns,
|
||||||
|
Dictionary<Band, int> Mults,
|
||||||
|
Dictionary<Band, int> Qs,
|
||||||
|
int MostMults,
|
||||||
|
int MostQs);
|
||||||
|
|
||||||
|
private sealed record Computed(int Mults, int Total, SummaryData? Summary, List<AvailableRow> Rows, string RowsKey);
|
||||||
|
|
||||||
|
private async Task Update()
|
||||||
|
{
|
||||||
Summary.IsVisible = session.Settings.AvailableShowsSummary;
|
Summary.IsVisible = session.Settings.AvailableShowsSummary;
|
||||||
bool multsOnly = session.Settings.AvailableMultsOnly;
|
bool multsOnly = session.Settings.AvailableMultsOnly;
|
||||||
ModeButton.Content = multsOnly ? "Mults" : "Mults & Qs";
|
ModeButton.Content = multsOnly ? "Mults" : "Mults & Qs";
|
||||||
ModeButton.Foreground = multsOnly ? Verdicts.NewMultiplier : Verdicts.Worth;
|
ModeButton.Foreground = multsOnly ? Verdicts.NewMultiplier : Verdicts.Worth;
|
||||||
if (session.Available is null)
|
if (session.Available is not { } available)
|
||||||
{
|
{
|
||||||
StatusText.Text = "no contest is open";
|
StatusText.Text = "no contest is open";
|
||||||
Spots.ItemsSource = Array.Empty<AvailableRow>();
|
Spots.ItemsSource = Array.Empty<AvailableRow>();
|
||||||
shownKey = "";
|
shownKey = "";
|
||||||
|
summaryKey = "";
|
||||||
|
Summary.Children.Clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// everything the other threads read is taken here
|
||||||
DateTime now = DateTime.UtcNow;
|
DateTime now = DateTime.UtcNow;
|
||||||
session.Bandmap.DropOlderThan(now);
|
Settings settings = session.Settings;
|
||||||
IReadOnlyList<AvailableStation> all = session.Available.All();
|
BandPlan plan = session.BandPlan;
|
||||||
if (Summary.IsVisible)
|
Bandmap bandmap = session.Bandmap;
|
||||||
|
HashSet<Band> picked = [.. clicked];
|
||||||
|
Frequency? tuned = session.Position?.Frequency;
|
||||||
|
bool summaryShown = Summary.IsVisible;
|
||||||
|
bandmap.DropOlderThan(now);
|
||||||
|
IReadOnlyList<AvailableStation> all = await available.AllAsync();
|
||||||
|
Computed computed = await Task.Run(() => Compute(all, now, settings, plan, bandmap, picked, tuned, summaryShown));
|
||||||
|
if (again)
|
||||||
{
|
{
|
||||||
BuildSummary(all);
|
return;
|
||||||
}
|
}
|
||||||
int mults = all.Count(s => s.IsMultiplier);
|
|
||||||
Title = $"Available Mults and Qs — {all.Count - mults} Qs · {mults} Mults · {all.Count} total";
|
if (computed.Summary is { } summary)
|
||||||
StatusText.Text = all.Count == 0
|
{
|
||||||
|
BuildSummary(summary, settings, picked);
|
||||||
|
}
|
||||||
|
Title = $"Available Mults and Qs — {computed.Total - computed.Mults} Qs · {computed.Mults} Mults · {computed.Total} total";
|
||||||
|
StatusText.Text = computed.Total == 0
|
||||||
? "No workable spots: check the contest, the bands and the cluster filters"
|
? "No workable spots: check the contest, the bands and the cluster filters"
|
||||||
: $"{mults} mults · {all.Count - mults} Qs · {all.Count} total";
|
: $"{computed.Mults} mults · {computed.Total - computed.Mults} Qs · {computed.Total} total";
|
||||||
if (IsPaused)
|
if (IsPaused)
|
||||||
{
|
{
|
||||||
refreshPending = true;
|
refreshPending = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
Show(computed.Rows, computed.RowsKey);
|
||||||
|
}
|
||||||
|
|
||||||
IReadOnlyList<string> preferred = session.Settings.SpotFilter.PreferredSpotters;
|
private Computed Compute(
|
||||||
Frequency? tuned = session.Position?.Frequency;
|
IReadOnlyList<AvailableStation> all,
|
||||||
|
DateTime now,
|
||||||
|
Settings settings,
|
||||||
|
BandPlan plan,
|
||||||
|
Bandmap bandmap,
|
||||||
|
HashSet<Band> picked,
|
||||||
|
Frequency? tuned,
|
||||||
|
bool summaryShown)
|
||||||
|
{
|
||||||
|
int mults = all.Count(s => s.IsMultiplier);
|
||||||
|
IReadOnlyList<string> preferred = settings.SpotFilter.PreferredSpotters;
|
||||||
List<AvailableRow> rows = all
|
List<AvailableRow> rows = all
|
||||||
.Where(s => (!multsOnly || s.IsMultiplier) && IsShown(s))
|
.Where(s => (!settings.AvailableMultsOnly || s.IsMultiplier) && IsShown(s, settings, plan, picked))
|
||||||
.Select(s => new AvailableRow(
|
.Select(s => new AvailableRow(
|
||||||
s,
|
s,
|
||||||
ModeName(s.Spot.Frequency),
|
ModeName(plan, s.Spot.Frequency),
|
||||||
now,
|
now,
|
||||||
session.Bandmap.Remaining(s.Spot),
|
bandmap.Remaining(s.Spot),
|
||||||
preferred.Contains(s.Spot.Spotter, StringComparer.OrdinalIgnoreCase),
|
preferred.Contains(s.Spot.Spotter, StringComparer.OrdinalIgnoreCase),
|
||||||
tuned is { } at && Math.Abs(at.Hertz - s.Spot.Frequency.Hertz) <= 100))
|
tuned is { } at && Math.Abs(at.Hertz - s.Spot.Frequency.Hertz) <= 100))
|
||||||
.ToList();
|
.ToList();
|
||||||
Show(rows);
|
return new Computed(
|
||||||
|
mults,
|
||||||
|
all.Count,
|
||||||
|
summaryShown ? SummaryOf(all, settings) : null,
|
||||||
|
rows,
|
||||||
|
string.Join(';', rows.Select(r => r.Key)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsShown(AvailableStation station)
|
private static bool IsShown(AvailableStation station, Settings settings, BandPlan plan, HashSet<Band> picked)
|
||||||
{
|
{
|
||||||
IReadOnlyList<string> bands = session.Settings.AvailableBands;
|
IReadOnlyList<string> bands = settings.AvailableBands;
|
||||||
IReadOnlyList<string> modes = session.Settings.AvailableModes;
|
IReadOnlyList<string> modes = settings.AvailableModes;
|
||||||
ModeCategory? category = session.BandPlan.ModeAt(station.Spot.Frequency);
|
ModeCategory? category = plan.ModeAt(station.Spot.Frequency);
|
||||||
return (clicked.Count == 0 || clicked.Contains(station.Band))
|
return (picked.Count == 0 || picked.Contains(station.Band))
|
||||||
&& (bands.Count == 0 || bands.Contains(station.Band.Name, StringComparer.OrdinalIgnoreCase))
|
&& (bands.Count == 0 || bands.Contains(station.Band.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
&& (modes.Count == 0 || category is null || modes.Contains(ModeKey(category.Value), StringComparer.OrdinalIgnoreCase));
|
&& (modes.Count == 0 || category is null || modes.Contains(ModeKey(category.Value), StringComparer.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
@@ -262,9 +339,8 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
|||||||
|
|
||||||
/// A refresh that changes nothing leaves the list alone, so the scroll
|
/// A refresh that changes nothing leaves the list alone, so the scroll
|
||||||
/// position and the picked line stay where the operator left them.
|
/// position and the picked line stay where the operator left them.
|
||||||
private void Show(List<AvailableRow> rows)
|
private void Show(List<AvailableRow> rows, string key)
|
||||||
{
|
{
|
||||||
string key = string.Join(';', rows.Select(r => r.Key));
|
|
||||||
if (key == shownKey && Spots.ItemsSource is not null)
|
if (key == shownKey && Spots.ItemsSource is not null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -280,13 +356,13 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
|||||||
}
|
}
|
||||||
string? picked = (Spots.SelectedItem as AvailableRow)?.Call;
|
string? picked = (Spots.SelectedItem as AvailableRow)?.Call;
|
||||||
Spots.ItemsSource = view;
|
Spots.ItemsSource = view;
|
||||||
if (picked is not null && rows.FirstOrDefault(r => r.Call == picked) is { } again)
|
if (picked is not null && rows.FirstOrDefault(r => r.Call == picked) is { } same)
|
||||||
{
|
{
|
||||||
Spots.SelectedItem = again;
|
Spots.SelectedItem = same;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ModeName(Frequency frequency) => session.BandPlan.ModeAt(frequency) switch
|
private static string ModeName(BandPlan plan, Frequency frequency) => plan.ModeAt(frequency) switch
|
||||||
{
|
{
|
||||||
ModeCategory.Cw => "CW",
|
ModeCategory.Cw => "CW",
|
||||||
ModeCategory.Phone => "SSB",
|
ModeCategory.Phone => "SSB",
|
||||||
@@ -309,22 +385,44 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
|||||||
|
|
||||||
// ---- summary table
|
// ---- summary table
|
||||||
|
|
||||||
private IReadOnlyList<Band> SummaryBands(IReadOnlyList<AvailableStation> all)
|
private IReadOnlyList<Band> SummaryBands(IReadOnlyList<AvailableStation> all) =>
|
||||||
|
SummaryBands(all, session.Settings);
|
||||||
|
|
||||||
|
private static IReadOnlyList<Band> SummaryBands(IReadOnlyList<AvailableStation> all, Settings settings)
|
||||||
{
|
{
|
||||||
IReadOnlyList<string> chosen = session.Settings.AvailableSummaryBands;
|
IReadOnlyList<string> chosen = settings.AvailableSummaryBands;
|
||||||
IEnumerable<Band> basis = chosen.Count > 0
|
IEnumerable<Band> basis = chosen.Count > 0
|
||||||
? chosen.Select(Bands.Named).OfType<Band>()
|
? chosen.Select(Bands.Named).OfType<Band>()
|
||||||
: Bands.Contest;
|
: Bands.Contest;
|
||||||
return [.. basis.Concat(all.Select(s => s.Band)).Distinct().OrderBy(b => b.Low.Hertz)];
|
return [.. basis.Concat(all.Select(s => s.Band)).Distinct().OrderBy(b => b.Low.Hertz)];
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BuildSummary(IReadOnlyList<AvailableStation> all)
|
private static SummaryData SummaryOf(IReadOnlyList<AvailableStation> all, Settings settings)
|
||||||
{
|
{
|
||||||
IReadOnlyList<Band> columns = SummaryBands(all);
|
IReadOnlyList<Band> columns = SummaryBands(all, settings);
|
||||||
Dictionary<Band, int> mults = columns.ToDictionary(b => b, b => all.Count(s => s.Band == b && s.IsMultiplier));
|
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));
|
Dictionary<Band, int> qs = columns.ToDictionary(b => b, b => all.Count(s => s.Band == b && !s.IsMultiplier));
|
||||||
int mostMults = mults.Values.Max();
|
return new SummaryData(columns, mults, qs, mults.Values.Max(), qs.Values.Max());
|
||||||
int mostQs = qs.Values.Max();
|
}
|
||||||
|
|
||||||
|
private void BuildSummary(SummaryData data, Settings settings, HashSet<Band> picked)
|
||||||
|
{
|
||||||
|
(IReadOnlyList<Band> columns, Dictionary<Band, int> mults, Dictionary<Band, int> qs, int mostMults, int mostQs) = data;
|
||||||
|
|
||||||
|
// the table is built again only when a number, a highlight or the
|
||||||
|
// font size is different from what it shows
|
||||||
|
string key = string.Join(
|
||||||
|
';',
|
||||||
|
columns.Select(b => $"{b.Name}:{mults[b]}:{qs[b]}:{picked.Contains(b)}"))
|
||||||
|
+ $"|{mostMults}|{mostQs}|{settings.AvailableFontSize}";
|
||||||
|
if (key == summaryKey)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
summaryKey = key;
|
||||||
|
Summary.Children.Clear();
|
||||||
|
Summary.RowDefinitions.Clear();
|
||||||
|
Summary.ColumnDefinitions.Clear();
|
||||||
|
|
||||||
for (int row = 0; row < 4; row++)
|
for (int row = 0; row < 4; row++)
|
||||||
{
|
{
|
||||||
@@ -344,7 +442,7 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
|||||||
{
|
{
|
||||||
Band band = columns[at];
|
Band band = columns[at];
|
||||||
int column = at + 1;
|
int column = at + 1;
|
||||||
Label(0, column, band.Name.TrimEnd('M', 'm'), e => ShowBands([band], null, e), clicked.Contains(band));
|
Label(0, column, band.Name.TrimEnd('M', 'm'), e => ShowBands([band], null, e), picked.Contains(band));
|
||||||
Label(1, column, Count(mults[band]), e => ShowBands([band], true, e), mults[band] > 0 && mults[band] == mostMults);
|
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(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));
|
Label(3, column, Count(mults[band] + qs[band]), e => ShowBands([band], null, e));
|
||||||
|
|||||||
@@ -99,14 +99,35 @@ public sealed class BandmapView : Control
|
|||||||
DrawSegments(context);
|
DrawSegments(context);
|
||||||
DrawScale(context, ink);
|
DrawScale(context, ink);
|
||||||
DrawVfos(context);
|
DrawVfos(context);
|
||||||
placed = [.. BandmapLayout.Place(Spots, scale, LabelHeight)
|
PlaceLabels();
|
||||||
.Select(l => l with { MarkY = l.MarkY + Inset, LabelY = l.LabelY + Inset })];
|
|
||||||
foreach (BandmapLabel label in placed)
|
foreach (BandmapLabel label in placed)
|
||||||
{
|
{
|
||||||
DrawLabel(context, label, ink);
|
DrawLabel(context, label, ink);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<Spot> placedSpots = [];
|
||||||
|
private BandmapScale? placedScale;
|
||||||
|
private double placedHeight;
|
||||||
|
|
||||||
|
/// Lays the callsigns out again only when the spots, the scale or the text
|
||||||
|
/// size are not what the last layout used. Redraws for any other reason, a
|
||||||
|
/// moving receiver bar for one, reuse it.
|
||||||
|
private void PlaceLabels()
|
||||||
|
{
|
||||||
|
if (placedScale == scale
|
||||||
|
&& placedHeight == LabelHeight
|
||||||
|
&& (ReferenceEquals(placedSpots, Spots) || placedSpots.SequenceEqual(Spots)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
placedSpots = Spots;
|
||||||
|
placedScale = scale;
|
||||||
|
placedHeight = LabelHeight;
|
||||||
|
placed = [.. BandmapLayout.Place(Spots, scale, LabelHeight)
|
||||||
|
.Select(l => l with { MarkY = l.MarkY + Inset, LabelY = l.LabelY + Inset })];
|
||||||
|
}
|
||||||
|
|
||||||
/// N1MM paints the CW, digital and phone parts of the band behind the
|
/// N1MM paints the CW, digital and phone parts of the band behind the
|
||||||
/// scale, the digital block over the other two.
|
/// scale, the digital block over the other two.
|
||||||
/// Anything past a band edge is painted as out of band, which is the only
|
/// Anything past a band edge is painted as out of band, which is the only
|
||||||
@@ -209,11 +230,19 @@ public sealed class BandmapView : Control
|
|||||||
protected override void OnPointerMoved(PointerEventArgs e)
|
protected override void OnPointerMoved(PointerEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnPointerMoved(e);
|
base.OnPointerMoved(e);
|
||||||
ToolTip.SetShowDelay(this, 0);
|
Spot? under = LabelAt(e.GetPosition(this))?.Spot;
|
||||||
ToolTip.SetTip(
|
if (Equals(under, tipFor))
|
||||||
this,
|
{
|
||||||
LabelAt(e.GetPosition(this)) is { } label ? SpotDescription.Of(label.Spot) : null);
|
return;
|
||||||
}
|
}
|
||||||
|
tipFor = under;
|
||||||
|
ToolTip.SetShowDelay(this, 0);
|
||||||
|
ToolTip.SetTip(this, under is null ? null : SpotDescription.Of(under));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The spot the tool tip describes, so moving the pointer along a callsign
|
||||||
|
/// does not build the text again.
|
||||||
|
private Spot? tipFor;
|
||||||
|
|
||||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -227,23 +256,56 @@ public sealed class BandmapView : Control
|
|||||||
FrequencyPicked?.Invoke(this, scale.At(at.Y - Inset));
|
FrequencyPicked?.Invoke(this, scale.At(at.Y - Inset));
|
||||||
}
|
}
|
||||||
|
|
||||||
private FormattedText Text(string text, IBrush brush, double size, bool bold = false, bool italic = false) =>
|
/// Shaping text is the dearest part of a redraw, and the same few hundred
|
||||||
new(
|
/// callsigns and frequency numbers are drawn every time, so each is kept.
|
||||||
|
private readonly Dictionary<(string Text, IBrush Brush, double Size, bool Bold, bool Italic), FormattedText> texts = [];
|
||||||
|
|
||||||
|
private FormattedText Text(string text, IBrush brush, double size, bool bold = false, bool italic = false)
|
||||||
|
{
|
||||||
|
if (texts.TryGetValue((text, brush, size, bold, italic), out FormattedText? kept))
|
||||||
|
{
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
if (texts.Count > 2000)
|
||||||
|
{
|
||||||
|
texts.Clear();
|
||||||
|
}
|
||||||
|
FormattedText made = new(
|
||||||
text,
|
text,
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
FlowDirection.LeftToRight,
|
FlowDirection.LeftToRight,
|
||||||
bold ? boldTypeface : italic ? italicTypeface : typeface,
|
bold ? boldTypeface : italic ? italicTypeface : typeface,
|
||||||
size,
|
size,
|
||||||
brush);
|
brush);
|
||||||
|
texts[(text, brush, size, bold, italic)] = made;
|
||||||
|
return made;
|
||||||
|
}
|
||||||
|
|
||||||
/// The form's text colour, which the theme sets in both light and dark.
|
/// The form's text colour, which the theme sets in both light and dark.
|
||||||
private IBrush Foreground() =>
|
private IBrush Foreground() =>
|
||||||
this.FindResource("FormForeground") as IBrush ?? Brushes.Gray;
|
this.FindResource("FormForeground") as IBrush ?? Brushes.Gray;
|
||||||
|
|
||||||
private static IBrush Opaque(IBrush ink, double opacity) =>
|
private readonly Dictionary<(IBrush Ink, double Opacity), IBrush> faded = [];
|
||||||
ink is ISolidColorBrush solid
|
|
||||||
? new SolidColorBrush(solid.Color, opacity)
|
/// Kept, so the same brush comes back each time and the text drawn with it
|
||||||
: ink;
|
/// is found in `texts`.
|
||||||
|
private IBrush Opaque(IBrush ink, double opacity)
|
||||||
|
{
|
||||||
|
if (ink is not ISolidColorBrush solid)
|
||||||
|
{
|
||||||
|
return ink;
|
||||||
|
}
|
||||||
|
if (!faded.TryGetValue((ink, opacity), out IBrush? brush))
|
||||||
|
{
|
||||||
|
if (faded.Count > 50)
|
||||||
|
{
|
||||||
|
faded.Clear();
|
||||||
|
}
|
||||||
|
brush = new SolidColorBrush(solid.Color, opacity);
|
||||||
|
faded[(ink, opacity)] = brush;
|
||||||
|
}
|
||||||
|
return brush;
|
||||||
|
}
|
||||||
|
|
||||||
private static IBrush BrushFor(VfoRole role) => role switch
|
private static IBrush BrushFor(VfoRole role) => role switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Avalonia.Threading;
|
|||||||
using Nonemm.Contests;
|
using Nonemm.Contests;
|
||||||
using Nonemm.Core;
|
using Nonemm.Core;
|
||||||
using Nonemm.Rig;
|
using Nonemm.Rig;
|
||||||
|
using Nonemm.Session;
|
||||||
using Nonemm.Spotting;
|
using Nonemm.Spotting;
|
||||||
|
|
||||||
namespace Nonemm.App.Windows;
|
namespace Nonemm.App.Windows;
|
||||||
@@ -76,7 +77,10 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
View.FrequencyPicked += (_, frequency) => tune(frequency, "");
|
View.FrequencyPicked += (_, frequency) => tune(frequency, "");
|
||||||
session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
|
Coalesced changed = new(Refresh);
|
||||||
|
EventHandler spotsChanged = (_, _) => changed.Request();
|
||||||
|
session.Bandmap.Changed += spotsChanged;
|
||||||
|
Closed += (_, _) => session.Bandmap.Changed -= spotsChanged;
|
||||||
EventHandler solarChanged = (_, _) => Dispatcher.UIThread.Post(ShowSolar);
|
EventHandler solarChanged = (_, _) => Dispatcher.UIThread.Post(ShowSolar);
|
||||||
session.SolarChanged += solarChanged;
|
session.SolarChanged += solarChanged;
|
||||||
Closed += (_, _) => session.SolarChanged -= solarChanged;
|
Closed += (_, _) => session.SolarChanged -= solarChanged;
|
||||||
@@ -127,6 +131,56 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
|||||||
View.Vfos = Vfos();
|
View.Vfos = Vfos();
|
||||||
View.Segments = session.BandPlan.For(band);
|
View.Segments = session.BandPlan.For(band);
|
||||||
View.InvalidateVisual();
|
View.InvalidateVisual();
|
||||||
|
_ = JudgeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What each callsign is worth, as of the last pass. The colours are looked
|
||||||
|
/// up when the view draws, so the log is never searched on the UI thread.
|
||||||
|
private Dictionary<Spot, Verdict?> verdicts = new(ReferenceEqualityComparer.Instance);
|
||||||
|
private bool judging;
|
||||||
|
private bool judgeAgain;
|
||||||
|
|
||||||
|
/// Judges every spot on another thread and draws again when that is done.
|
||||||
|
/// A request made while a pass is running is answered by one more pass.
|
||||||
|
private async Task JudgeAsync()
|
||||||
|
{
|
||||||
|
if (judging)
|
||||||
|
{
|
||||||
|
judgeAgain = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
judging = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
do
|
||||||
|
{
|
||||||
|
judgeAgain = false;
|
||||||
|
OperatingPosition? position = session.Position;
|
||||||
|
Mode? mode = position?.Mode;
|
||||||
|
string contest = position?.Contest.Name ?? "";
|
||||||
|
IReadOnlyList<Spot> asked = View.Spots;
|
||||||
|
Dictionary<Spot, Verdict?> judged = await Task.Run(() =>
|
||||||
|
{
|
||||||
|
Dictionary<Spot, Verdict?> found = new(ReferenceEqualityComparer.Instance);
|
||||||
|
foreach (Spot spot in asked)
|
||||||
|
{
|
||||||
|
found[spot] = VerdictFor(position, mode, contest, spot);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
});
|
||||||
|
if (judgeAgain)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
verdicts = judged;
|
||||||
|
View.InvalidateVisual();
|
||||||
|
}
|
||||||
|
while (judgeAgain);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
judging = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The bandmap's spots with the pseudospot for the CQ frequency, when the
|
/// The bandmap's spots with the pseudospot for the CQ frequency, when the
|
||||||
@@ -206,11 +260,13 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
|||||||
};
|
};
|
||||||
|
|
||||||
private IBrush Colour(Spot spot) =>
|
private IBrush Colour(Spot spot) =>
|
||||||
spot.IsCqFrequency ? CqBrush : Verdicts.Colour(VerdictFor(spot));
|
spot.IsCqFrequency
|
||||||
|
? CqBrush
|
||||||
|
: Verdicts.Colour(verdicts.GetValueOrDefault(spot));
|
||||||
|
|
||||||
private Verdict? VerdictFor(Spot spot)
|
private static Verdict? VerdictFor(OperatingPosition? position, Mode? mode, string contest, Spot spot)
|
||||||
{
|
{
|
||||||
if (session.Position is null)
|
if (position is null || mode is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -219,14 +275,14 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
|||||||
// N1MM marks a busy frequency as worked, so it is painted like one
|
// N1MM marks a busy frequency as worked, so it is painted like one
|
||||||
return Verdict.Dupe;
|
return Verdict.Dupe;
|
||||||
}
|
}
|
||||||
return session.Position.Log.Judge(new Qso
|
return position.Log.Judge(new Qso
|
||||||
{
|
{
|
||||||
Id = "",
|
Id = "",
|
||||||
TimestampUtc = DateTime.UtcNow,
|
TimestampUtc = DateTime.UtcNow,
|
||||||
Call = spot.Call,
|
Call = spot.Call,
|
||||||
Frequency = spot.Frequency,
|
Frequency = spot.Frequency,
|
||||||
Mode = session.Position.Mode,
|
Mode = mode,
|
||||||
ContestName = session.Position.Contest.Name,
|
ContestName = contest,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,14 +29,49 @@ public sealed partial class CheckWindow : RefreshableWindow
|
|||||||
|
|
||||||
public override void RefreshForEntry() => Refresh();
|
public override void RefreshForEntry() => Refresh();
|
||||||
|
|
||||||
|
private bool running;
|
||||||
|
private bool again;
|
||||||
|
|
||||||
|
/// The search runs on another thread, so a key press never waits for it.
|
||||||
|
/// A request that comes in while one is running is answered by one more
|
||||||
|
/// run afterwards, which sees the latest call.
|
||||||
public override void Refresh()
|
public override void Refresh()
|
||||||
{
|
{
|
||||||
Columns.Children.Clear();
|
if (running)
|
||||||
if (session.Check is null)
|
|
||||||
{
|
{
|
||||||
|
again = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
IReadOnlyList<CheckColumn> columns = session.Check.Columns();
|
_ = RunAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunAsync()
|
||||||
|
{
|
||||||
|
running = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
do
|
||||||
|
{
|
||||||
|
again = false;
|
||||||
|
await ShowColumns();
|
||||||
|
}
|
||||||
|
while (again);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShowColumns()
|
||||||
|
{
|
||||||
|
if (session.Check is null)
|
||||||
|
{
|
||||||
|
Columns.Children.Clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
IReadOnlyList<CheckColumn> columns = await session.Check.ColumnsAsync();
|
||||||
|
Columns.Children.Clear();
|
||||||
Columns.ColumnDefinitions.Clear();
|
Columns.ColumnDefinitions.Clear();
|
||||||
foreach (CheckColumn _ in columns)
|
foreach (CheckColumn _ in columns)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Windows;
|
||||||
|
|
||||||
|
/// Runs an action on the UI thread once for any number of requests made before
|
||||||
|
/// it runs. A radio turning its dial or a busy cluster raises an event many
|
||||||
|
/// times a second, and each window only needs to redraw once the burst has been
|
||||||
|
/// handled.
|
||||||
|
public sealed class Coalesced(Action action)
|
||||||
|
{
|
||||||
|
private int pending;
|
||||||
|
|
||||||
|
/// Safe to call from any thread.
|
||||||
|
public void Request()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref pending, 1) == 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
Volatile.Write(ref pending, 0);
|
||||||
|
action();
|
||||||
|
},
|
||||||
|
DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,7 +67,9 @@ public sealed partial class EntryWindow : Window
|
|||||||
this.radioNumber = radioNumber;
|
this.radioNumber = radioNumber;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
BuildFunctionKeys();
|
BuildFunctionKeys();
|
||||||
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
|
// a radio being tuned raises this many times a second
|
||||||
|
Coalesced changed = new(Refresh);
|
||||||
|
session.Changed += (_, _) => changed.Request();
|
||||||
// a keyer speaks on its own thread, and a recording that would not
|
// a keyer speaks on its own thread, and a recording that would not
|
||||||
// play has nowhere else to be said
|
// play has nowhere else to be said
|
||||||
session.Reported += (_, what) => Dispatcher.UIThread.Post(() => Status(what));
|
session.Reported += (_, what) => Dispatcher.UIThread.Post(() => Status(what));
|
||||||
@@ -949,7 +951,7 @@ public sealed partial class EntryWindow : Window
|
|||||||
RefreshSelf();
|
RefreshSelf();
|
||||||
foreach (Window window in openWindows.Values)
|
foreach (Window window in openWindows.Values)
|
||||||
{
|
{
|
||||||
(window as RefreshableWindow)?.Refresh();
|
(window as RefreshableWindow)?.RefreshFromSession();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,24 +49,55 @@ public sealed class GraylineView : Control
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly Pen CoastPen = new(Land, 1);
|
||||||
|
private Geometry? coastlines;
|
||||||
|
private Size coastlinesFor;
|
||||||
|
|
||||||
|
/// The coastlines are thousands of short segments, so they are built into
|
||||||
|
/// one shape that is kept until the window changes size.
|
||||||
private void DrawCoastlines(DrawingContext context)
|
private void DrawCoastlines(DrawingContext context)
|
||||||
{
|
{
|
||||||
Pen pen = new(Land, 1);
|
if (coastlines is null || coastlinesFor != Bounds.Size)
|
||||||
|
{
|
||||||
|
coastlines = BuildCoastlines();
|
||||||
|
coastlinesFor = Bounds.Size;
|
||||||
|
}
|
||||||
|
context.DrawGeometry(null, CoastPen, coastlines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private StreamGeometry BuildCoastlines()
|
||||||
|
{
|
||||||
|
StreamGeometry shape = new();
|
||||||
|
using StreamGeometryContext path = shape.Open();
|
||||||
foreach (IReadOnlyList<Point> line in WorldMap.Coastlines)
|
foreach (IReadOnlyList<Point> line in WorldMap.Coastlines)
|
||||||
{
|
{
|
||||||
for (int at = 1; at < line.Count; at++)
|
bool open = false;
|
||||||
|
for (int at = 0; at < line.Count; at++)
|
||||||
{
|
{
|
||||||
Point from = line[at - 1];
|
|
||||||
Point to = line[at];
|
Point to = line[at];
|
||||||
// a line that runs off one edge and back on at the other is the
|
// a line that runs off one edge and back on at the other is the
|
||||||
// date line being crossed, and is left undrawn
|
// date line being crossed, and is left undrawn
|
||||||
if (Math.Abs(to.X - from.X) > 180)
|
if (open && Math.Abs(to.X - line[at - 1].X) > 180)
|
||||||
{
|
{
|
||||||
continue;
|
path.EndFigure(false);
|
||||||
|
open = false;
|
||||||
}
|
}
|
||||||
context.DrawLine(pen, At(from.Y, from.X), At(to.Y, to.X));
|
if (open)
|
||||||
|
{
|
||||||
|
path.LineTo(At(to.Y, to.X));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
path.BeginFigure(At(to.Y, to.X), isFilled: false);
|
||||||
|
open = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (open)
|
||||||
|
{
|
||||||
|
path.EndFigure(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shape;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The night is one shape, its edge the terminator: the latitude where the
|
/// The night is one shape, its edge the terminator: the latitude where the
|
||||||
|
|||||||
@@ -25,23 +25,40 @@ public sealed partial class GraylineWindow : RefreshableWindow
|
|||||||
clock = new DispatcherTimer { Interval = TimeSpan.FromMinutes(1) };
|
clock = new DispatcherTimer { Interval = TimeSpan.FromMinutes(1) };
|
||||||
clock.Tick += (_, _) => Refresh();
|
clock.Tick += (_, _) => Refresh();
|
||||||
clock.Start();
|
clock.Start();
|
||||||
session.Changed += WhenSessionChanged;
|
|
||||||
Closed += (_, _) =>
|
Closed += (_, _) =>
|
||||||
{
|
{
|
||||||
clock.Stop();
|
clock.Stop();
|
||||||
session.Changed -= WhenSessionChanged;
|
later?.Cancel();
|
||||||
};
|
};
|
||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
private OperatingPosition? Radio => session.Position;
|
private OperatingPosition? Radio => session.Position;
|
||||||
|
|
||||||
public override void RefreshForEntry() => Refresh();
|
private Throttled? later;
|
||||||
|
|
||||||
|
/// The map is not watched closely: a station typed or spotted shows up
|
||||||
|
/// within fifteen seconds, and the grey line moves too slowly to see.
|
||||||
|
public override void RefreshForEntry() => RefreshFromSession();
|
||||||
|
|
||||||
|
public override void RefreshFromSession()
|
||||||
|
{
|
||||||
|
later ??= new Throttled(TimeSpan.FromSeconds(15), Refresh);
|
||||||
|
later.Request();
|
||||||
|
}
|
||||||
|
|
||||||
public override void Refresh()
|
public override void Refresh()
|
||||||
{
|
{
|
||||||
Map.Moment = DateTime.UtcNow;
|
DateTime now = DateTime.UtcNow;
|
||||||
Map.Markers = Markers();
|
IReadOnlyList<MapMarker> markers = Markers();
|
||||||
|
// the terminator moves a quarter of a degree a minute, so a redraw
|
||||||
|
// for nothing but the clock can wait
|
||||||
|
if (now - Map.Moment < TimeSpan.FromSeconds(30) && Map.Markers.SequenceEqual(markers))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map.Moment = now;
|
||||||
|
Map.Markers = markers;
|
||||||
Map.InvalidateVisual();
|
Map.InvalidateVisual();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +112,5 @@ public sealed partial class GraylineWindow : RefreshableWindow
|
|||||||
private CountryLookup? Where(string call) =>
|
private CountryLookup? Where(string call) =>
|
||||||
call.Trim().Length == 0 ? null : session.Countries?.Find(call.Trim());
|
call.Trim().Length == 0 ? null : session.Countries?.Find(call.Trim());
|
||||||
|
|
||||||
private void WhenSessionChanged(object? sender, EventArgs e) =>
|
|
||||||
Dispatcher.UIThread.Post(Refresh);
|
|
||||||
|
|
||||||
private sealed record Station(string Call, double Latitude, double Longitude, IBrush Colour);
|
private sealed record Station(string Call, double Latitude, double Longitude, IBrush Colour);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,9 +222,14 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
// the view stays where it is and an open editing box stays open
|
// the view stays where it is and an open editing box stays open
|
||||||
bool atBottom = ScrolledToBottom();
|
bool atBottom = ScrolledToBottom();
|
||||||
for (int at = 0; at < shownRows.Count; at++)
|
for (int at = 0; at < shownRows.Count; at++)
|
||||||
|
{
|
||||||
|
// a contact that was not edited is the same object, and the
|
||||||
|
// row has nothing to redraw
|
||||||
|
if (!ReferenceEquals(shownRows[at].Qso, qsos[at]))
|
||||||
{
|
{
|
||||||
shownRows[at].Show(qsos[at], VerdictFor(qsos[at]));
|
shownRows[at].Show(qsos[at], VerdictFor(qsos[at]));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
bool grown = qsos.Count > shownRows.Count;
|
bool grown = qsos.Count > shownRows.Count;
|
||||||
for (int at = shownRows.Count; at < qsos.Count; at++)
|
for (int at = shownRows.Count; at < qsos.Count; at++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ public abstract class RefreshableWindow : Window
|
|||||||
{
|
{
|
||||||
public abstract void Refresh();
|
public abstract void Refresh();
|
||||||
|
|
||||||
|
/// Called instead of `Refresh` when the session changes. A window that
|
||||||
|
/// does not need to follow every change redraws later here, while `Refresh`
|
||||||
|
/// stays immediate for what the operator does in the window itself.
|
||||||
|
public virtual void RefreshFromSession() => Refresh();
|
||||||
|
|
||||||
/// Called instead of `Refresh` when the entry window's call or exchange
|
/// Called instead of `Refresh` when the entry window's call or exchange
|
||||||
/// changes. A window that shows nothing about them does nothing.
|
/// changes. A window that shows nothing about them does nothing.
|
||||||
public virtual void RefreshForEntry()
|
public virtual void RefreshForEntry()
|
||||||
|
|||||||
@@ -19,13 +19,18 @@ public sealed partial class ScoreWindow : RefreshableWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// What the table and the total line showed last, so a refresh that
|
||||||
|
/// changes nothing leaves the controls alone.
|
||||||
|
private string shown = "";
|
||||||
|
|
||||||
public override void Refresh()
|
public override void Refresh()
|
||||||
{
|
{
|
||||||
|
if (session.Logging is null)
|
||||||
|
{
|
||||||
|
shown = "";
|
||||||
Table.Children.Clear();
|
Table.Children.Clear();
|
||||||
Table.ColumnDefinitions.Clear();
|
Table.ColumnDefinitions.Clear();
|
||||||
Table.RowDefinitions.Clear();
|
Table.RowDefinitions.Clear();
|
||||||
if (session.Logging is null)
|
|
||||||
{
|
|
||||||
TotalText.Text = "no contest is open";
|
TotalText.Text = "no contest is open";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -39,32 +44,28 @@ public sealed partial class ScoreWindow : RefreshableWindow
|
|||||||
.ToList();
|
.ToList();
|
||||||
bool hasKinds = session.Logging.Contest.HasQtcTraffic;
|
bool hasKinds = session.Logging.Contest.HasQtcTraffic;
|
||||||
|
|
||||||
columnCount = hasKinds ? 5 : 4;
|
List<(string[] Cells, bool Header)> rows = [(["Band", "Q/QTC", "QSOs", "Points", "Mults"], true)];
|
||||||
for (int column = 0; column < columnCount; column++)
|
|
||||||
{
|
|
||||||
Table.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
|
|
||||||
}
|
|
||||||
AddRow(0, "Band", "Q/QTC", "QSOs", "Points", "Mults", header: true);
|
|
||||||
|
|
||||||
int row = 1;
|
|
||||||
foreach ((ScoreKey key, ScoreTally line) in lines)
|
foreach ((ScoreKey key, ScoreTally line) in lines)
|
||||||
{
|
{
|
||||||
AddRow(
|
rows.Add((
|
||||||
row++,
|
[
|
||||||
key.Band,
|
key.Band,
|
||||||
key.Kind,
|
key.Kind,
|
||||||
line.Qsos.ToString(),
|
line.Qsos.ToString(),
|
||||||
line.Points.ToString(),
|
line.Points.ToString(),
|
||||||
line.TotalMultipliers.ToString());
|
line.TotalMultipliers.ToString(),
|
||||||
|
],
|
||||||
|
false));
|
||||||
}
|
}
|
||||||
AddRow(
|
rows.Add((
|
||||||
row,
|
[
|
||||||
"Total",
|
"Total",
|
||||||
hasKinds ? "All" : "",
|
hasKinds ? "All" : "",
|
||||||
session.Logging.Log.Tally.Qsos.ToString(),
|
session.Logging.Log.Tally.Qsos.ToString(),
|
||||||
session.Logging.Log.Tally.Points.ToString(),
|
session.Logging.Log.Tally.Points.ToString(),
|
||||||
session.Logging.Log.Tally.TotalMultipliers.ToString(),
|
session.Logging.Log.Tally.TotalMultipliers.ToString(),
|
||||||
header: true);
|
],
|
||||||
|
true));
|
||||||
|
|
||||||
TimeSpan on = OperatingTime.On(
|
TimeSpan on = OperatingTime.On(
|
||||||
session.Logging.Log.Qsos,
|
session.Logging.Log.Qsos,
|
||||||
@@ -72,10 +73,31 @@ public sealed partial class ScoreWindow : RefreshableWindow
|
|||||||
TimeSpan off = OperatingTime.Off(
|
TimeSpan off = OperatingTime.Off(
|
||||||
session.Logging.Log.Qsos,
|
session.Logging.Log.Qsos,
|
||||||
session.Logging.Contest.MinimumOffTime);
|
session.Logging.Contest.MinimumOffTime);
|
||||||
TotalText.Text =
|
string total =
|
||||||
$"Claimed score {session.Logging.Log.TotalScore:N0}"
|
$"Claimed score {session.Logging.Log.TotalScore:N0}"
|
||||||
+ $" on {Hours(on)} off {Hours(off)}"
|
+ $" on {Hours(on)} off {Hours(off)}"
|
||||||
+ BandChanges();
|
+ BandChanges();
|
||||||
|
|
||||||
|
string signature = hasKinds + "\n" + string.Join('\n', rows.Select(r => string.Join('\t', r.Cells)));
|
||||||
|
TotalText.Text = total;
|
||||||
|
if (signature == shown)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shown = signature;
|
||||||
|
|
||||||
|
Table.Children.Clear();
|
||||||
|
Table.ColumnDefinitions.Clear();
|
||||||
|
Table.RowDefinitions.Clear();
|
||||||
|
columnCount = hasKinds ? 5 : 4;
|
||||||
|
for (int column = 0; column < columnCount; column++)
|
||||||
|
{
|
||||||
|
Table.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
|
||||||
|
}
|
||||||
|
for (int row = 0; row < rows.Count; row++)
|
||||||
|
{
|
||||||
|
AddRow(row, rows[row].Cells, rows[row].Header);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The band changes made and the allowance, for a contest that limits
|
/// The band changes made and the allowance, for a contest that limits
|
||||||
@@ -92,19 +114,12 @@ public sealed partial class ScoreWindow : RefreshableWindow
|
|||||||
private static string Hours(TimeSpan time) =>
|
private static string Hours(TimeSpan time) =>
|
||||||
$"{(int)time.TotalHours}:{time.Minutes:00}";
|
$"{(int)time.TotalHours}:{time.Minutes:00}";
|
||||||
|
|
||||||
private void AddRow(
|
private void AddRow(int row, string[] all, bool header)
|
||||||
int row,
|
|
||||||
string band,
|
|
||||||
string kind,
|
|
||||||
string qsos,
|
|
||||||
string points,
|
|
||||||
string mults,
|
|
||||||
bool header = false)
|
|
||||||
{
|
{
|
||||||
Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||||
string[] cells = columnCount == 5
|
string[] cells = columnCount == 5
|
||||||
? [band, kind, qsos, points, mults]
|
? all
|
||||||
: [band, qsos, points, mults];
|
: [all[0], all[2], all[3], all[4]];
|
||||||
for (int column = 0; column < cells.Length; column++)
|
for (int column = 0; column < cells.Length; column++)
|
||||||
{
|
{
|
||||||
TextBlock text = new()
|
TextBlock text = new()
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ using Avalonia.Interactivity;
|
|||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using Avalonia.Threading;
|
||||||
using Nonemm.App.Configuration;
|
using Nonemm.App.Configuration;
|
||||||
|
using Nonemm.Core;
|
||||||
using Nonemm.Session;
|
using Nonemm.Session;
|
||||||
|
|
||||||
namespace Nonemm.App.Windows;
|
namespace Nonemm.App.Windows;
|
||||||
@@ -55,55 +57,118 @@ public sealed partial class StatisticsWindow : RefreshableWindow
|
|||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
ShowPaletteBox();
|
ShowPaletteBox();
|
||||||
|
DispatcherTimer every = new() { Interval = TimeSpan.FromSeconds(30) };
|
||||||
|
every.Tick += (_, _) => Refresh();
|
||||||
|
every.Start();
|
||||||
|
Closed += (_, _) => every.Stop();
|
||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many contacts the figures were counted from.
|
||||||
|
private int countShown = -1;
|
||||||
|
|
||||||
|
/// The numbers are not watched as they change. They are counted again
|
||||||
|
/// every thirty seconds and when a contact has been logged or deleted; the
|
||||||
|
/// refresh button and the field boxes are immediate.
|
||||||
|
public override void RefreshFromSession()
|
||||||
|
{
|
||||||
|
if (session.Logging is { } logging && logging.Log.Qsos.Count != countShown)
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private StatisticsField Row =>
|
private StatisticsField Row =>
|
||||||
StatisticsFields.Find(StatisticsFields.Rows, session.Settings.StatsRow);
|
StatisticsFields.Find(StatisticsFields.Rows, session.Settings.StatsRow);
|
||||||
|
|
||||||
private StatisticsField Column =>
|
private StatisticsField Column =>
|
||||||
StatisticsFields.Find(StatisticsFields.Columns, session.Settings.StatsColumn);
|
StatisticsFields.Find(StatisticsFields.Columns, session.Settings.StatsColumn);
|
||||||
|
|
||||||
|
private bool running;
|
||||||
|
private bool again;
|
||||||
|
|
||||||
|
/// Counting the log is done on another thread, so a refresh never holds up
|
||||||
|
/// the entry window. A request made while one is running is answered by one
|
||||||
|
/// more run afterwards, and the run it replaces is not drawn.
|
||||||
public override void Refresh()
|
public override void Refresh()
|
||||||
{
|
{
|
||||||
if (session.Logging is null)
|
if (running)
|
||||||
{
|
{
|
||||||
Table.Columns.Clear();
|
again = true;
|
||||||
Table.ItemsSource = Array.Empty<StatisticsRow>();
|
return;
|
||||||
Chart.Columns = [];
|
}
|
||||||
Chart.Series = [];
|
_ = RunAsync();
|
||||||
Chart.InvalidateVisual();
|
}
|
||||||
laid = null;
|
|
||||||
Empty("no contest is open");
|
private async Task RunAsync()
|
||||||
|
{
|
||||||
|
running = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
do
|
||||||
|
{
|
||||||
|
again = false;
|
||||||
|
await Update();
|
||||||
|
}
|
||||||
|
while (again);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Update()
|
||||||
|
{
|
||||||
|
if (session.Logging is not { } logging)
|
||||||
|
{
|
||||||
|
countShown = -1;
|
||||||
|
ShowNothing("no contest is open");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
StatisticsPivot pivot = StatisticsPivot.Of(
|
// the log is changed on this thread, so the counting works on a copy
|
||||||
session.Logging.Log.Qsos,
|
IReadOnlyList<Qso> qsos = [.. logging.Log.Qsos];
|
||||||
Row,
|
countShown = qsos.Count;
|
||||||
Column,
|
StatisticsField row = Row;
|
||||||
session.Logging.Instance.StartDate,
|
StatisticsField column = Column;
|
||||||
session.Settings.StatsIncludeDupes);
|
DateTime start = logging.Instance.StartDate;
|
||||||
Title = $"Statistics for {session.Logging.Contest.Name}"
|
bool dupes = session.Settings.StatsIncludeDupes;
|
||||||
+ $" - {session.Logging.Instance.StartDate:yyyy-MM-dd}"
|
(StatisticsPivot pivot, StatisticsTable? table) = await Task.Run(() =>
|
||||||
+ $" - {Column.Name} by {Row.Name}";
|
|
||||||
if (pivot.IsEmpty)
|
|
||||||
{
|
{
|
||||||
Table.Columns.Clear();
|
StatisticsPivot counted = StatisticsPivot.Of(qsos, row, column, start, dupes);
|
||||||
Table.ItemsSource = Array.Empty<StatisticsRow>();
|
return (counted, counted.IsEmpty ? null : StatisticsTable.Of(counted));
|
||||||
Chart.Columns = [];
|
});
|
||||||
Chart.Series = [];
|
if (again)
|
||||||
Chart.InvalidateVisual();
|
{
|
||||||
laid = null;
|
return;
|
||||||
Empty("no contacts are counted");
|
}
|
||||||
|
|
||||||
|
Title = $"Statistics for {logging.Contest.Name}"
|
||||||
|
+ $" - {start:yyyy-MM-dd}"
|
||||||
|
+ $" - {column.Name} by {row.Name}";
|
||||||
|
if (table is null)
|
||||||
|
{
|
||||||
|
ShowNothing("no contacts are counted");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
EmptyText.IsVisible = false;
|
EmptyText.IsVisible = false;
|
||||||
Table.IsVisible = true;
|
Table.IsVisible = true;
|
||||||
FillTable(pivot);
|
FillTable(table);
|
||||||
FillChart(pivot);
|
FillChart(pivot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ShowNothing(string why)
|
||||||
|
{
|
||||||
|
Table.Columns.Clear();
|
||||||
|
Table.ItemsSource = Array.Empty<StatisticsRow>();
|
||||||
|
Chart.Columns = [];
|
||||||
|
Chart.Series = [];
|
||||||
|
Chart.InvalidateVisual();
|
||||||
|
laid = null;
|
||||||
|
Empty(why);
|
||||||
|
}
|
||||||
|
|
||||||
private void Empty(string why)
|
private void Empty(string why)
|
||||||
{
|
{
|
||||||
EmptyText.Text = why;
|
EmptyText.Text = why;
|
||||||
@@ -111,9 +176,9 @@ public sealed partial class StatisticsWindow : RefreshableWindow
|
|||||||
Table.IsVisible = false;
|
Table.IsVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void FillTable(StatisticsPivot pivot)
|
private void FillTable(StatisticsTable table)
|
||||||
{
|
{
|
||||||
laid = StatisticsTable.Of(pivot);
|
laid = table;
|
||||||
Table.Columns.Clear();
|
Table.Columns.Clear();
|
||||||
for (int at = 0; at < laid.Headings.Count; at++)
|
for (int at = 0; at < laid.Headings.Count; at++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -143,26 +143,45 @@ public sealed partial class TelnetWindow : RefreshableWindow
|
|||||||
private void OnConnectionChanged(object? sender, bool connected) =>
|
private void OnConnectionChanged(object? sender, bool connected) =>
|
||||||
Dispatcher.UIThread.Post(Refresh);
|
Dispatcher.UIThread.Post(Refresh);
|
||||||
|
|
||||||
/// `strong` marks the lines from a preferred spotter. The view stays on the
|
/// `strong` marks the lines from a preferred spotter. Lines wait here and go
|
||||||
|
/// into the list together, because a busy node sends dozens a second and
|
||||||
|
/// each one added alone lays the list out again. The view stays on the
|
||||||
/// newest line; a burst of lines is scrolled once, after the last of them.
|
/// newest line; a burst of lines is scrolled once, after the last of them.
|
||||||
private void Show(string line, IBrush? colour, bool strong = false) => Dispatcher.UIThread.Post(() =>
|
private void Show(string line, IBrush? colour, bool strong = false) => Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
while (Traffic.Items.Count >= LinesKept)
|
waiting.Add((line, colour, strong));
|
||||||
|
flushWaiting ??= new Coalesced(AddWaiting);
|
||||||
|
flushWaiting.Request();
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly List<(string Line, IBrush? Colour, bool Strong)> waiting = [];
|
||||||
|
private Coalesced? flushWaiting;
|
||||||
|
|
||||||
|
private void AddWaiting()
|
||||||
|
{
|
||||||
|
// lines that would be pushed out again at once are not added
|
||||||
|
int skip = Math.Max(0, waiting.Count - LinesKept);
|
||||||
|
int excess = Math.Max(0, Traffic.Items.Count + waiting.Count - skip - LinesKept);
|
||||||
|
for (int removed = 0; removed < excess && Traffic.Items.Count > 0; removed++)
|
||||||
{
|
{
|
||||||
Traffic.Items.RemoveAt(0);
|
Traffic.Items.RemoveAt(0);
|
||||||
}
|
}
|
||||||
|
foreach ((string line, IBrush? colour, bool strong) in waiting.Skip(skip))
|
||||||
|
{
|
||||||
Traffic.Items.Add(new TextBlock
|
Traffic.Items.Add(new TextBlock
|
||||||
{
|
{
|
||||||
Text = line,
|
Text = line,
|
||||||
Foreground = colour,
|
Foreground = colour,
|
||||||
FontWeight = strong ? FontWeight.Bold : FontWeight.Normal,
|
FontWeight = strong ? FontWeight.Bold : FontWeight.Normal,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
waiting.Clear();
|
||||||
if (!scrollPending)
|
if (!scrollPending)
|
||||||
{
|
{
|
||||||
scrollPending = true;
|
scrollPending = true;
|
||||||
Dispatcher.UIThread.Post(ScrollToNewest, DispatcherPriority.Background);
|
Dispatcher.UIThread.Post(ScrollToNewest, DispatcherPriority.Background);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
private void ScrollToNewest()
|
private void ScrollToNewest()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Windows;
|
||||||
|
|
||||||
|
/// Runs an action once, `delay` after the first request, however many requests
|
||||||
|
/// come in meanwhile. It is for windows that do not have to follow every
|
||||||
|
/// change: the picture is at most `delay` old. Call it on the UI thread.
|
||||||
|
public sealed class Throttled
|
||||||
|
{
|
||||||
|
private readonly DispatcherTimer timer;
|
||||||
|
|
||||||
|
public Throttled(TimeSpan delay, Action action)
|
||||||
|
{
|
||||||
|
timer = new DispatcherTimer { Interval = delay };
|
||||||
|
timer.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
timer.Stop();
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Request()
|
||||||
|
{
|
||||||
|
if (!timer.IsEnabled)
|
||||||
|
{
|
||||||
|
timer.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops a request that is waiting, for a window that has closed.
|
||||||
|
public void Cancel() => timer.Stop();
|
||||||
|
}
|
||||||
@@ -32,7 +32,6 @@ public sealed partial class VoiceKeyerWindow : RefreshableWindow
|
|||||||
RecordBox.LostFocus += (_, _) => SaveCommands();
|
RecordBox.LostFocus += (_, _) => SaveCommands();
|
||||||
PttBox.IsCheckedChanged += (_, _) => SaveCommands();
|
PttBox.IsCheckedChanged += (_, _) => SaveCommands();
|
||||||
OperatorBox.SelectionChanged += (_, _) => Refresh();
|
OperatorBox.SelectionChanged += (_, _) => Refresh();
|
||||||
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
|
|
||||||
FillOperators();
|
FillOperators();
|
||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ public sealed class ContestLog
|
|||||||
private readonly Dictionary<ScoreKey, ScoreTally> rows = [];
|
private readonly Dictionary<ScoreKey, ScoreTally> rows = [];
|
||||||
private ScoreTally tally = new();
|
private ScoreTally tally = new();
|
||||||
|
|
||||||
|
/// `Judge` is called from a background thread by the windows that judge
|
||||||
|
/// every spot, while the UI thread adds and edits contacts. Both take this
|
||||||
|
/// lock around the indexes. The lock can be entered again by the thread
|
||||||
|
/// that holds it, because a rebuild judges each contact.
|
||||||
|
private readonly Lock guard = new();
|
||||||
|
|
||||||
public ContestLog(
|
public ContestLog(
|
||||||
Contest contest,
|
Contest contest,
|
||||||
StationInfo me,
|
StationInfo me,
|
||||||
@@ -67,6 +73,14 @@ public sealed class ContestLog
|
|||||||
|
|
||||||
/// What logging this contact right now would do, without logging it.
|
/// What logging this contact right now would do, without logging it.
|
||||||
public Verdict Judge(Qso candidate)
|
public Verdict Judge(Qso candidate)
|
||||||
|
{
|
||||||
|
lock (guard)
|
||||||
|
{
|
||||||
|
return JudgeLocked(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Verdict JudgeLocked(Qso candidate)
|
||||||
{
|
{
|
||||||
if (!candidate.IsClaimed)
|
if (!candidate.IsClaimed)
|
||||||
{
|
{
|
||||||
@@ -102,6 +116,8 @@ public sealed class ContestLog
|
|||||||
|
|
||||||
/// Adds the contact and returns it with points and multiplier flags filled in.
|
/// Adds the contact and returns it with points and multiplier flags filled in.
|
||||||
public Qso Add(Qso qso)
|
public Qso Add(Qso qso)
|
||||||
|
{
|
||||||
|
lock (guard)
|
||||||
{
|
{
|
||||||
Verdict verdict = Judge(qso);
|
Verdict verdict = Judge(qso);
|
||||||
Qso scored = ApplyVerdict(qso, verdict);
|
Qso scored = ApplyVerdict(qso, verdict);
|
||||||
@@ -109,47 +125,65 @@ public sealed class ContestLog
|
|||||||
Index(scored, verdict);
|
Index(scored, verdict);
|
||||||
return scored;
|
return scored;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Loads contacts read back from storage. Points and multiplier flags in
|
/// Loads contacts read back from storage. Points and multiplier flags in
|
||||||
/// the stored rows are ignored and worked out again from the rules.
|
/// the stored rows are ignored and worked out again from the rules.
|
||||||
public void Restore(IEnumerable<Qso> stored)
|
public void Restore(IEnumerable<Qso> stored)
|
||||||
|
{
|
||||||
|
lock (guard)
|
||||||
{
|
{
|
||||||
qsos.AddRange(stored);
|
qsos.AddRange(stored);
|
||||||
Rebuild();
|
Rebuild();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Adds a contact that may be older than ones already logged, which is what
|
/// Adds a contact that may be older than ones already logged, which is what
|
||||||
/// a contact from another station of the entry can be, and scores the log
|
/// a contact from another station of the entry can be, and scores the log
|
||||||
/// again: the earlier of two contacts is the one that claims a multiplier.
|
/// again: the earlier of two contacts is the one that claims a multiplier.
|
||||||
public void Insert(Qso qso)
|
public void Insert(Qso qso)
|
||||||
|
{
|
||||||
|
lock (guard)
|
||||||
{
|
{
|
||||||
qsos.Add(qso);
|
qsos.Add(qso);
|
||||||
Rebuild();
|
Rebuild();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `Insert` for several contacts, scoring the log once instead of once per
|
/// `Insert` for several contacts, scoring the log once instead of once per
|
||||||
/// contact. A resync sends a few hundred in a burst.
|
/// contact. A resync sends a few hundred in a burst.
|
||||||
public void InsertRange(IEnumerable<Qso> arrived)
|
public void InsertRange(IEnumerable<Qso> arrived)
|
||||||
|
{
|
||||||
|
lock (guard)
|
||||||
{
|
{
|
||||||
qsos.AddRange(arrived);
|
qsos.AddRange(arrived);
|
||||||
Rebuild();
|
Rebuild();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Remove(string id)
|
public void Remove(string id)
|
||||||
|
{
|
||||||
|
lock (guard)
|
||||||
{
|
{
|
||||||
qsos.RemoveAll(q => q.Id == id);
|
qsos.RemoveAll(q => q.Id == id);
|
||||||
Rebuild();
|
Rebuild();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `Remove` for several contacts, scoring the log once.
|
/// `Remove` for several contacts, scoring the log once.
|
||||||
public void RemoveRange(IReadOnlyCollection<string> ids)
|
public void RemoveRange(IReadOnlyCollection<string> ids)
|
||||||
{
|
{
|
||||||
HashSet<string> gone = [.. ids];
|
HashSet<string> gone = [.. ids];
|
||||||
|
lock (guard)
|
||||||
|
{
|
||||||
qsos.RemoveAll(q => gone.Contains(q.Id));
|
qsos.RemoveAll(q => gone.Contains(q.Id));
|
||||||
Rebuild();
|
Rebuild();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Replace(Qso qso)
|
public void Replace(Qso qso)
|
||||||
|
{
|
||||||
|
lock (guard)
|
||||||
{
|
{
|
||||||
int at = qsos.FindIndex(q => q.Id == qso.Id);
|
int at = qsos.FindIndex(q => q.Id == qso.Id);
|
||||||
if (at < 0)
|
if (at < 0)
|
||||||
@@ -159,6 +193,7 @@ public sealed class ContestLog
|
|||||||
qsos[at] = qso;
|
qsos[at] = qso;
|
||||||
Rebuild();
|
Rebuild();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A station worked before, under the contest's dupe rule. A contest with
|
/// A station worked before, under the contest's dupe rule. A contest with
|
||||||
/// a dupe window — N1MM's `DupeQSOMinutesAgo` — lets the same station be
|
/// a dupe window — N1MM's `DupeQSOMinutesAgo` — lets the same station be
|
||||||
@@ -284,7 +319,13 @@ public sealed class ContestLog
|
|||||||
/// rescores what it touches — but the stored points and multiplier flags
|
/// rescores what it touches — but the stored points and multiplier flags
|
||||||
/// are written from this, and a caller that is about to write them asks for
|
/// are written from this, and a caller that is about to write them asks for
|
||||||
/// a pass first.
|
/// a pass first.
|
||||||
public void Rescore() => Rebuild();
|
public void Rescore()
|
||||||
|
{
|
||||||
|
lock (guard)
|
||||||
|
{
|
||||||
|
Rebuild();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Rescores the whole log. A removed or edited contact can hand its
|
/// Rescores the whole log. A removed or edited contact can hand its
|
||||||
/// multiplier to a later one, which only a full pass gets right.
|
/// multiplier to a later one, which only a full pass gets right.
|
||||||
|
|||||||
@@ -47,16 +47,32 @@ public sealed class AvailableStations
|
|||||||
|
|
||||||
/// Every spotted station that is not a dupe, multipliers first and then in
|
/// Every spotted station that is not a dupe, multipliers first and then in
|
||||||
/// frequency order.
|
/// frequency order.
|
||||||
public IReadOnlyList<AvailableStation> All()
|
public IReadOnlyList<AvailableStation> All() =>
|
||||||
|
Build(bandmap.All(), position.Mode, position.Contest, position.Me);
|
||||||
|
|
||||||
|
/// `All` on a thread-pool thread, for the window that would otherwise
|
||||||
|
/// judge every spot between two key presses. What the position holds is
|
||||||
|
/// read here, on the caller's thread, and the log locks itself while it
|
||||||
|
/// judges.
|
||||||
|
public Task<IReadOnlyList<AvailableStation>> AllAsync()
|
||||||
|
{
|
||||||
|
IReadOnlyList<Spot> spots = bandmap.All();
|
||||||
|
Mode mode = position.Mode;
|
||||||
|
Contest contest = position.Contest;
|
||||||
|
StationInfo me = position.Me;
|
||||||
|
return Task.Run(() => Build(spots, mode, contest, me));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<AvailableStation> Build(IReadOnlyList<Spot> spots, Mode mode, Contest contest, StationInfo me)
|
||||||
{
|
{
|
||||||
List<AvailableStation> found = [];
|
List<AvailableStation> found = [];
|
||||||
foreach (Spot spot in bandmap.All())
|
foreach (Spot spot in spots)
|
||||||
{
|
{
|
||||||
if (spot.Band is not { } band || !spot.IsStation)
|
if (spot.Band is not { } band || !spot.IsStation)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Verdict verdict = position.Log.Judge(Candidate(spot));
|
Verdict verdict = position.Log.Judge(Candidate(spot, mode, contest, me));
|
||||||
if (!verdict.IsDupe)
|
if (!verdict.IsDupe)
|
||||||
{
|
{
|
||||||
found.Add(new AvailableStation(
|
found.Add(new AvailableStation(
|
||||||
@@ -73,7 +89,7 @@ public sealed class AvailableStations
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Qso Candidate(Spot spot)
|
private Qso Candidate(Spot spot, Mode mode, Contest contest, StationInfo me)
|
||||||
{
|
{
|
||||||
Qso qso = new()
|
Qso qso = new()
|
||||||
{
|
{
|
||||||
@@ -81,13 +97,13 @@ public sealed class AvailableStations
|
|||||||
TimestampUtc = DateTime.UtcNow,
|
TimestampUtc = DateTime.UtcNow,
|
||||||
Call = spot.Call,
|
Call = spot.Call,
|
||||||
Frequency = spot.Frequency,
|
Frequency = spot.Frequency,
|
||||||
Mode = position.Mode,
|
Mode = mode,
|
||||||
ContestName = position.Contest.Name,
|
ContestName = contest.Name,
|
||||||
};
|
};
|
||||||
return ExchangeSlots.ApplyHistory(
|
return ExchangeSlots.ApplyHistory(
|
||||||
CountryFields.Apply(qso, position.Session.Countries),
|
CountryFields.Apply(qso, position.Session.Countries),
|
||||||
position.Session.History,
|
position.Session.History,
|
||||||
position.Contest,
|
contest,
|
||||||
position.Me);
|
me);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,44 @@ public sealed class CheckWindowSources
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Columns`, with the searching done on another thread. The master
|
||||||
|
/// database holds tens of thousands of calls, and searching it between two
|
||||||
|
/// key presses is what makes typing stutter. The log, the history and the
|
||||||
|
/// bandmap are read here on the caller's thread, because the log is
|
||||||
|
/// changed there; the caller's thread also judges the few calls that are
|
||||||
|
/// shown, because that reads the log too.
|
||||||
|
public async Task<IReadOnlyList<CheckColumn>> ColumnsAsync(int limit = 24)
|
||||||
|
{
|
||||||
|
string query = (Elsewhere ?? session.Entry.Call).Trim().ToUpperInvariant();
|
||||||
|
IReadOnlyList<string> logged = LoggedCalls();
|
||||||
|
IReadOnlyList<string> history = HistoryCalls();
|
||||||
|
IReadOnlyList<string> spotted = SpottedCalls();
|
||||||
|
CheckColumn exchange = ExchangeColumn(limit);
|
||||||
|
bool searching = query.Length >= PartialMatching.ShortestUsefulQuery;
|
||||||
|
List<PartialMatch> none = [];
|
||||||
|
(List<PartialMatch> Log, List<PartialMatch> Master, List<PartialMatch> History, List<PartialMatch> Bandmap) found =
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
if (!searching)
|
||||||
|
{
|
||||||
|
return (none, none, none, none);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
Best(logged, query, limit),
|
||||||
|
database.Matches(query, limit).Select(m => m).ToList(),
|
||||||
|
Best(history, query, limit),
|
||||||
|
Best(spotted, query, limit));
|
||||||
|
});
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new CheckColumn(CheckSource.Log, logged.Count, [.. found.Log.Select(Judged)]),
|
||||||
|
new CheckColumn(CheckSource.Database, database.Count, [.. found.Master.Select(Judged)]),
|
||||||
|
new CheckColumn(CheckSource.History, history.Count, [.. found.History.Select(Judged)]),
|
||||||
|
new CheckColumn(CheckSource.Bandmap, spotted.Count, [.. found.Bandmap.Select(Judged)]),
|
||||||
|
exchange,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/// What the exchange box with the cursor in it could hold: the ARRL
|
/// What the exchange box with the cursor in it could hold: the ARRL
|
||||||
/// sections, or the states and provinces. A serial number or a report has
|
/// sections, or the states and provinces. A serial number or a report has
|
||||||
/// no list behind it, so the column stays empty.
|
/// no list behind it, so the column stays empty.
|
||||||
@@ -81,19 +119,26 @@ public sealed class CheckWindowSources
|
|||||||
{
|
{
|
||||||
return new CheckColumn(source, calls.Count, []);
|
return new CheckColumn(source, calls.Count, []);
|
||||||
}
|
}
|
||||||
List<CheckCandidate> found = [];
|
return new CheckColumn(source, calls.Count, [.. Best(calls, query, limit).Select(Judged)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The calls that match the query, the closest first. It reads nothing but
|
||||||
|
/// its arguments, so it can run on any thread.
|
||||||
|
private static List<PartialMatch> Best(IReadOnlyList<string> calls, string query, int limit)
|
||||||
|
{
|
||||||
|
List<PartialMatch> found = [];
|
||||||
foreach (string call in calls)
|
foreach (string call in calls)
|
||||||
{
|
{
|
||||||
PartialMatchQuality? quality = PartialMatching.Judge(query, call);
|
PartialMatchQuality? quality = PartialMatching.Judge(query, call);
|
||||||
if (quality is not null)
|
if (quality is not null)
|
||||||
{
|
{
|
||||||
found.Add(Judged(new PartialMatch(call, quality.Value)));
|
found.Add(new PartialMatch(call, quality.Value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new CheckColumn(
|
return [.. found
|
||||||
source,
|
.OrderBy(m => m.Quality)
|
||||||
calls.Count,
|
.ThenBy(m => m.Call, StringComparer.Ordinal)
|
||||||
found.OrderBy(c => c.Quality).ThenBy(c => c.Text, StringComparer.Ordinal).Take(limit).ToList());
|
.Take(limit)];
|
||||||
}
|
}
|
||||||
|
|
||||||
private CheckCandidate Judged(PartialMatch match) =>
|
private CheckCandidate Judged(PartialMatch match) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user