From acd22a9b5e198568a1fc9380c606eb33e75a2979 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 4 Sep 2026 07:47:30 +0000 Subject: [PATCH] Count the log under any two of its fields N1MM's statistics window. `StatisticsPivot` counts the contacts under one row field and one column field, with the row total, the running total and the column totals N1MM prints. The fields are N1MM's own two lists: 29 down the rows, 20 of them across the columns, sorted the way N1MM's SQL sorts them, so zone 10 comes after zone 2 and 80M before 20M. The window has N1MM's two tabs. The table is the grid with `Tot` and `Accum` and a `Total` line; the graph is a stacked column per row, one segment per column, in one of N1MM's twelve palettes. "Include Dupes & X-QSOs" counts the contacts left out of the claimed score and the ones marked as dupes. Checked against a 3775-contact CQ WW SSB log: 3762 counted, which is what `select count(*) ... where CLAIMEDQSO = 1 and ContactType <> 'D'` returns, and the per-band totals match band by band. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU --- README.md | 3 +- docs/unfinished.md | 22 ++ src/Nonemm.App/Configuration/Settings.cs | 16 + src/Nonemm.App/Windows/ChartPalettes.cs | 45 +++ src/Nonemm.App/Windows/EntryWindow.Menu.cs | 4 + src/Nonemm.App/Windows/EntryWindow.axaml | 1 + src/Nonemm.App/Windows/StatisticsChart.cs | 229 +++++++++++++ src/Nonemm.App/Windows/StatisticsRow.cs | 27 ++ src/Nonemm.App/Windows/StatisticsWindow.axaml | 48 +++ .../Windows/StatisticsWindow.axaml.cs | 312 ++++++++++++++++++ src/Nonemm.Session/PivotKey.cs | 33 ++ src/Nonemm.Session/StatisticsField.cs | 27 ++ src/Nonemm.Session/StatisticsFields.cs | 102 ++++++ src/Nonemm.Session/StatisticsPivot.cs | 111 +++++++ .../StatisticsPivotTests.cs | 166 ++++++++++ 15 files changed, 1145 insertions(+), 1 deletion(-) create mode 100644 src/Nonemm.App/Windows/ChartPalettes.cs create mode 100644 src/Nonemm.App/Windows/StatisticsChart.cs create mode 100644 src/Nonemm.App/Windows/StatisticsRow.cs create mode 100644 src/Nonemm.App/Windows/StatisticsWindow.axaml create mode 100644 src/Nonemm.App/Windows/StatisticsWindow.axaml.cs create mode 100644 src/Nonemm.Session/PivotKey.cs create mode 100644 src/Nonemm.Session/StatisticsField.cs create mode 100644 src/Nonemm.Session/StatisticsFields.cs create mode 100644 src/Nonemm.Session/StatisticsPivot.cs create mode 100644 tests/Nonemm.Session.Tests/StatisticsPivotTests.cs diff --git a/README.md b/README.md index 1853dbb..94cb968 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ scorer: red for a dupe, green for a new multiplier, blue for points. | Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out | | While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file | | While transmitting | a digital message is held in the transmit pane and fed to the engine a character at a time, so what has not gone out can still be rewritten; what has gone out is red, and the engine idles when it catches up with the cursor | -| Windows | entry, log, check, bandmap, available mults and Qs, score summary, grey line, telnet, digital interface | +| Windows | entry, log, check, bandmap, available mults and Qs, score summary, statistics, grey line, telnet, digital interface | +| Statistics | the log counted under any two of its fields, as N1MM's table with its `Tot` and `Accum` columns or as a stacked column graph | | Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations | | Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own | | Cluster | DX cluster over telnet: a telnet window with the node's traffic, the published list of nodes, command buttons and spot filters, spots feeding the bandmap, Alt+P to spot a station | diff --git a/docs/unfinished.md b/docs/unfinished.md index a6be3c2..5d63d50 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -664,6 +664,28 @@ What still differs, and why: nothing and counts no multiplier; the code is trimmed here and counts. Both contacts are the same station in the 2026 log. +**The statistics window: what N1MM has and this does not.** The window counts +the log under any two of its fields, in N1MM's own two lists — 29 fields down +the rows, 20 of them across the columns — with N1MM's `Tot` and `Accum` columns, +its `Total` line, its "Include Dupes & X-QSOs" box and its stacked column graph. +Left out: printing to a printer, which needs a print dialog this program has +nowhere else, and N1MM's font size spinner, because the theme dialog sets the +fonts here. Copy All copies the table as text on both tabs; N1MM copies the +graph as an image on the graph tab, which the clipboard on Linux would need a +per-toolkit path for, so the graph is written to a PNG through File ▸ Save Graph +instead. + +The palette names are N1MM's twelve, which are the .NET chart control's. The +colours are not: they are chosen to match the name, because the control's own +values are not published anywhere this could read them from. Berry is purple +through magenta, Fire is red through yellow, and so on. + +**The statistics graph is drawn here rather than by a chart library.** +`StatisticsChart` draws the axis, the stacked columns, the labels and the +legend. It has no tooltips, no zoom and no 3-D, all of which N1MM's chart +control gives it for free. A column label that will not fit under its column is +turned on its side, and one that will not fit even then is left out. + ## Known rough edges **The band plan covers 160M to 2M only.** 60M and everything above 2M get no diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 67d05f4..b74f397 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -208,6 +208,22 @@ public sealed record Settings public string QtcRttySendAllEnding { get; init; } = QtcMessages.DefaultSendAllEnding; + /// What the statistics window counts down the rows and across the columns. + /// N1MM opens on the contacts per hand per hour, so this program does too. + public string StatsRow { get; init; } = "Hour"; + + public string StatsColumn { get; init; } = "Band"; + + public string StatsPalette { get; init; } = "Berry"; + + /// N1MM's "Include Dupes & X-QSOs". + public bool StatsIncludeDupes { get; init; } + + public bool StatsShowTitle { get; init; } = true; + + /// Which of the statistics window's two tabs was last open. + public bool StatsOnGraph { get; init; } + /// Sub-band boundaries the operator has changed. Empty means the defaults /// in `BandPlan.Default`; an entry replaces one band's boundaries. public IReadOnlyList SubBands { get; init; } = []; diff --git a/src/Nonemm.App/Windows/ChartPalettes.cs b/src/Nonemm.App/Windows/ChartPalettes.cs new file mode 100644 index 0000000..ca84739 --- /dev/null +++ b/src/Nonemm.App/Windows/ChartPalettes.cs @@ -0,0 +1,45 @@ +using Avalonia.Media; + +namespace Nonemm.App.Windows; + +/// The twelve palettes N1MM's statistics graph offers. The names are the ones +/// in N1MM's box, which are the .NET chart control's palettes; the colours are +/// chosen to match the name, not copied out of that control. +public static class ChartPalettes +{ + public static readonly IReadOnlyList Names = + [ + "Berry", "Bright", "Bright Pastel", "Chocolate", "Earth Tones", "Excel", + "Fire", "Gray Scale", "Light", "Pastel", "Sea Green", "Semi Transparent", + ]; + + private static readonly Dictionary Colours = new() + { + ["Berry"] = [0x8A2BE2, 0xBA55D3, 0xC71585, 0xDA70D6, 0x7B68EE, 0x9370DB, 0xFF69B4, 0x4B0082], + ["Bright"] = [0x1F77B4, 0xFF7F0E, 0x2CA02C, 0xD62728, 0x9467BD, 0x8C564B, 0xE377C2, 0x17BECF], + ["Bright Pastel"] = [0x418CF0, 0xFCB441, 0xE0400A, 0x056492, 0xBFBFBF, 0x1A3B69, 0xFFE382, 0x129CDD], + ["Chocolate"] = [0x5B3A29, 0x8B4513, 0xA0522D, 0xCD853F, 0xD2B48C, 0x6B4226, 0xB87333, 0xE3C6A0], + ["Earth Tones"] = [0x6B8E23, 0x808000, 0xBDB76B, 0x8B7355, 0x556B2F, 0xA0522D, 0xC0A16B, 0x4F5D2F], + ["Excel"] = [0x4472C4, 0xED7D31, 0xA5A5A5, 0xFFC000, 0x5B9BD5, 0x70AD47, 0x264478, 0x9E480E], + ["Fire"] = [0xB22222, 0xE34A00, 0xFF8C00, 0xFFC000, 0xFFE873, 0x8B0000, 0xFF4500, 0xFFA07A], + ["Gray Scale"] = [0x2B2B2B, 0x4D4D4D, 0x6E6E6E, 0x8F8F8F, 0xB0B0B0, 0xC9C9C9, 0xE0E0E0, 0xF2F2F2], + ["Light"] = [0xA6CEE3, 0xB2DF8A, 0xFDBF6F, 0xCAB2D6, 0xFB9A99, 0xFFFF99, 0xB3E2CD, 0xD9D9D9], + ["Pastel"] = [0xAEC7E8, 0xFFBB78, 0x98DF8A, 0xFF9896, 0xC5B0D5, 0xC49C94, 0xF7B6D2, 0x9EDAE5], + ["Sea Green"] = [0x2E8B57, 0x3CB371, 0x20B2AA, 0x66CDAA, 0x008B8B, 0x5F9EA0, 0x8FBC8F, 0x006400], + ["Semi Transparent"] = [0x1F77B4, 0xFF7F0E, 0x2CA02C, 0xD62728, 0x9467BD, 0x8C564B, 0xE377C2, 0x17BECF], + }; + + /// The palette of that name, or the first one when there is no such name. + /// A series past the end of the list takes a colour again from the start, + /// as the chart control does. + public static IReadOnlyList Of(string name) + { + uint[] values = Colours.TryGetValue(name, out uint[]? found) ? found : Colours["Berry"]; + byte alpha = name == "Semi Transparent" ? (byte)0xA0 : (byte)0xFF; + return [.. values.Select(v => Color.FromArgb( + alpha, + (byte)(v >> 16), + (byte)(v >> 8), + (byte)v))]; + } +} diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs index 5f45657..30a743c 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs @@ -431,6 +431,10 @@ public sealed partial class EntryWindow private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session)); + /// N1MM's statistics window: the log counted under any two of its fields. + private void OnShowStatistics(object? sender, RoutedEventArgs e) => + Show(() => new StatisticsWindow(session)); + private void OnShowNetworkStatus(object? sender, RoutedEventArgs e) => Show(() => new NetworkStatusWindow(session)); diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml index 11f3fdf..33d542a 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml +++ b/src/Nonemm.App/Windows/EntryWindow.axaml @@ -122,6 +122,7 @@ + diff --git a/src/Nonemm.App/Windows/StatisticsChart.cs b/src/Nonemm.App/Windows/StatisticsChart.cs new file mode 100644 index 0000000..60f7f58 --- /dev/null +++ b/src/Nonemm.App/Windows/StatisticsChart.cs @@ -0,0 +1,229 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace Nonemm.App.Windows; + +/// One column of the graph: the label under it and the count in each series. +public sealed record ChartColumn(string Label, IReadOnlyList Values); + +/// The statistics window's graph. N1MM draws a stacked column per row of the +/// table, one segment per column of the table, with the column values in a +/// legend along the bottom. +public sealed class StatisticsChart : Control +{ + private const double Gutter = 12; + private const double AxisWidth = 52; + private const double LegendSwatch = 10; + private const double LegendRowHeight = 16; + private const double TitleHeight = 26; + + /// Under this width a column is drawn without a gap either side, because + /// the gap would take more room than the column. + private const double NarrowColumn = 4; + + private readonly Typeface typeface = new(new FontFamily("monospace")); + + public IReadOnlyList Columns { get; set; } = []; + + /// The name of each series, which is one value of the table's column field. + public IReadOnlyList Series { get; set; } = []; + + public IReadOnlyList Palette { get; set; } = ChartPalettes.Of("Berry"); + + public string Title { get; set; } = ""; + + public bool ShowTitle { get; set; } = true; + + public override void Render(DrawingContext context) + { + IBrush ink = Foreground(); + // the window's own colour, so the graph written out to a file has a + // background under it rather than a transparent one + if (this.FindResource("FormBackground") is IBrush sheet) + { + context.FillRectangle(sheet, new Rect(Bounds.Size)); + } + if (Columns.Count == 0 || Series.Count == 0) + { + Write(context, "no contacts to graph", new Point(Gutter, Gutter), ink); + return; + } + + double top = Gutter; + if (ShowTitle && Title.Length > 0) + { + FormattedText title = Text(Title, 14, ink, bold: true); + context.DrawText(title, new Point((Bounds.Width - title.Width) / 2, top)); + top += TitleHeight; + } + + double legend = LegendHeight(); + double labels = LabelHeight(); + Rect plot = new( + AxisWidth, + top, + Math.Max(1, Bounds.Width - AxisWidth - Gutter), + Math.Max(1, Bounds.Height - top - legend - labels - Gutter)); + int highest = Highest(); + DrawAxis(context, plot, highest, ink); + DrawColumns(context, plot, highest, ink); + DrawLegend(context, new Rect(Gutter, Bounds.Height - legend, Bounds.Width - (2 * Gutter), legend), ink); + } + + /// The tallest stack, rounded up to a round number so the top gridline is + /// one a reader can divide by. + private int Highest() + { + int tallest = Columns.Max(c => c.Values.Sum()); + if (tallest <= 0) + { + return 1; + } + int step = (int)Math.Pow(10, Math.Floor(Math.Log10(tallest))); + return (int)(Math.Ceiling((double)tallest / step) * step); + } + + private void DrawAxis(DrawingContext context, Rect plot, int highest, IBrush ink) + { + Pen line = new(Opaque(ink, 0.35), 1); + Pen grid = new(Opaque(ink, 0.15), 1); + const int Lines = 4; + for (int at = 0; at <= Lines; at++) + { + double y = plot.Bottom - (plot.Height * at / Lines); + context.DrawLine(at == 0 ? line : grid, new Point(plot.Left, y), new Point(plot.Right, y)); + FormattedText value = Text( + (highest * at / Lines).ToString(CultureInfo.InvariantCulture), + 11, + Opaque(ink, 0.7)); + context.DrawText(value, new Point(plot.Left - value.Width - 6, y - (value.Height / 2))); + } + context.DrawLine(line, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom)); + } + + private void DrawColumns(DrawingContext context, Rect plot, int highest, IBrush ink) + { + double slot = plot.Width / Columns.Count; + double gap = slot > NarrowColumn ? Math.Min(slot * 0.2, 6) : 0; + double width = Math.Max(1, slot - gap); + for (int at = 0; at < Columns.Count; at++) + { + double left = plot.Left + (at * slot) + (gap / 2); + double bottom = plot.Bottom; + for (int series = 0; series < Columns[at].Values.Count; series++) + { + int count = Columns[at].Values[series]; + if (count <= 0) + { + continue; + } + double height = plot.Height * count / highest; + context.FillRectangle( + new SolidColorBrush(ColourOf(series)), + new Rect(left, bottom - height, width, height)); + bottom -= height; + } + DrawColumnLabel(context, Columns[at].Label, left + (width / 2), plot.Bottom + 3, slot, ink); + } + } + + /// A label wider than its column is turned on its side, which is what keeps + /// an hour-by-hour graph readable. + private void DrawColumnLabel( + DrawingContext context, + string label, + double centre, + double top, + double slot, + IBrush ink) + { + FormattedText text = Text(label, 11, Opaque(ink, 0.8)); + if (text.Width <= slot - 2) + { + context.DrawText(text, new Point(centre - (text.Width / 2), top)); + return; + } + if (slot < 7) + { + return; + } + using (context.PushTransform( + Matrix.CreateRotation(Math.PI / 2) * Matrix.CreateTranslation(centre + (text.Height / 2), top))) + { + context.DrawText(text, new Point(0, 0)); + } + } + + private void DrawLegend(DrawingContext context, Rect area, IBrush ink) + { + double x = area.Left; + double y = area.Top; + for (int at = 0; at < Series.Count; at++) + { + FormattedText name = Text(Series[at].Length > 0 ? Series[at] : "(none)", 11, ink); + double width = LegendSwatch + 4 + name.Width + 12; + if (x + width > area.Right && x > area.Left) + { + x = area.Left; + y += LegendRowHeight; + } + context.FillRectangle( + new SolidColorBrush(ColourOf(at)), + new Rect(x, y + 3, LegendSwatch, LegendSwatch)); + context.DrawText(name, new Point(x + LegendSwatch + 4, y)); + x += width; + } + } + + private double LegendHeight() + { + double x = 0; + int rows = 1; + double width = Math.Max(1, Bounds.Width - (2 * Gutter)); + foreach (string series in Series) + { + double item = LegendSwatch + 4 + + Text(series.Length > 0 ? series : "(none)", 11, Brushes.Gray).Width + 12; + if (x + item > width && x > 0) + { + x = 0; + rows++; + } + x += item; + } + return (rows * LegendRowHeight) + Gutter; + } + + /// Room under the columns for the labels: one line when they fit side by + /// side, the longest label on its side when they do not. + private double LabelHeight() + { + double slot = Math.Max(1, Bounds.Width - AxisWidth - Gutter) / Math.Max(1, Columns.Count); + IEnumerable labels = Columns.Select(c => Text(c.Label, 11, Brushes.Gray)); + return labels.All(t => t.Width <= slot - 2) + ? LegendRowHeight + : Columns.Max(c => Text(c.Label, 11, Brushes.Gray).Width) + 6; + } + + private Color ColourOf(int series) => Palette[series % Palette.Count]; + + private FormattedText Text(string text, double size, IBrush ink, bool bold = false) => + new( + text, + CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, + bold ? new Typeface(typeface.FontFamily, weight: FontWeight.Bold) : typeface, + size, + ink); + + private void Write(DrawingContext context, string text, Point at, IBrush ink) => + context.DrawText(Text(text, 12, ink), at); + + private IBrush Foreground() => + this.FindResource("SystemControlForegroundBaseHighBrush") as IBrush ?? Brushes.Gray; + + private static IBrush Opaque(IBrush ink, double opacity) => + ink is ISolidColorBrush solid ? new SolidColorBrush(solid.Color, opacity) : ink; +} diff --git a/src/Nonemm.App/Windows/StatisticsRow.cs b/src/Nonemm.App/Windows/StatisticsRow.cs new file mode 100644 index 0000000..f1aa686 --- /dev/null +++ b/src/Nonemm.App/Windows/StatisticsRow.cs @@ -0,0 +1,27 @@ +namespace Nonemm.App.Windows; + +/// One line of the statistics table. The grid reaches the cells through the +/// indexer, so the columns can follow the two fields chosen instead of being +/// fixed in the XAML. +public sealed class StatisticsRow +{ + private readonly string[] cells; + + public StatisticsRow(IReadOnlyList cells, bool isTotal = false) + { + this.cells = [.. cells]; + IsTotal = isTotal; + } + + /// The last line of the table, which holds the column totals. + public bool IsTotal { get; } + + public IReadOnlyList Cells => cells; + + /// `at` is the column number as text: the grid passes what stands in the + /// binding path, and a column is bound to `[3]`. + public string this[string at] => + int.TryParse(at, out int column) && column >= 0 && column < cells.Length + ? cells[column] + : ""; +} diff --git a/src/Nonemm.App/Windows/StatisticsWindow.axaml b/src/Nonemm.App/Windows/StatisticsWindow.axaml new file mode 100644 index 0000000..cc3a68a --- /dev/null +++ b/src/Nonemm.App/Windows/StatisticsWindow.axaml @@ -0,0 +1,48 @@ + + + + + + + + + + + + +