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 @@ + + + + + + + + + + + + +