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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
@@ -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<string> Names =
|
||||
[
|
||||
"Berry", "Bright", "Bright Pastel", "Chocolate", "Earth Tones", "Excel",
|
||||
"Fire", "Gray Scale", "Light", "Pastel", "Sea Green", "Semi Transparent",
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, uint[]> 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<Color> 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))];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
<MenuItem Header="Network Status" Click="OnShowNetworkStatus" />
|
||||
<MenuItem Header="Grey Line" Click="OnShowGrayline" />
|
||||
<MenuItem Header="Score Summary" Click="OnShowScore" />
|
||||
<MenuItem Header="Statistics" Click="OnShowStatistics" />
|
||||
<MenuItem Header="Telnet" Click="OnShowTelnet" />
|
||||
<MenuItem Header="Digital Interface" Click="OnShowDigital" />
|
||||
</MenuItem>
|
||||
|
||||
@@ -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<int> 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<ChartColumn> Columns { get; set; } = [];
|
||||
|
||||
/// The name of each series, which is one value of the table's column field.
|
||||
public IReadOnlyList<string> Series { get; set; } = [];
|
||||
|
||||
public IReadOnlyList<Color> 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<FormattedText> 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;
|
||||
}
|
||||
@@ -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<string> 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<string> 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]
|
||||
: "";
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Nonemm.App.Windows"
|
||||
x:Class="Nonemm.App.Windows.StatisticsWindow"
|
||||
Title="Statistics" Width="940" Height="560">
|
||||
<DockPanel>
|
||||
<Menu DockPanel.Dock="Top">
|
||||
<MenuItem Header="_File">
|
||||
<MenuItem Header="Copy All" Click="OnCopyAll" InputGesture="Ctrl+C" />
|
||||
<MenuItem Name="PrintToFileItem" Header="Print to File…" Click="OnPrintToFile" />
|
||||
<MenuItem Name="SaveGraphItem" Header="Save Graph…" Click="OnSaveGraph" />
|
||||
<MenuItem Name="ShowTitleItem" Header="Show Title" ToggleType="CheckBox"
|
||||
Click="OnToggleTitle" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="6,4" Spacing="6">
|
||||
<Button Name="RefreshButton" Content="Refresh" Click="OnRefreshClicked" />
|
||||
<TextBlock Text="Row" VerticalAlignment="Center" />
|
||||
<ComboBox Name="RowBox" Width="130" SelectionChanged="OnFieldChanged" />
|
||||
<TextBlock Text="Column" VerticalAlignment="Center" />
|
||||
<ComboBox Name="ColumnBox" Width="130" SelectionChanged="OnFieldChanged" />
|
||||
<TextBlock Name="PaletteLabel" Text="Palette" VerticalAlignment="Center" />
|
||||
<ComboBox Name="PaletteBox" Width="150" SelectionChanged="OnPaletteChanged" />
|
||||
<CheckBox Name="DupesBox" Content="Include Dupes & X-QSOs" IsCheckedChanged="OnDupesChanged" />
|
||||
</StackPanel>
|
||||
|
||||
<TabControl Name="Tabs" SelectionChanged="OnTabChanged">
|
||||
<TabItem Header="Statistics">
|
||||
<Grid>
|
||||
<Grid.Styles>
|
||||
<!-- the DataGrid's own font settings do not reach the cells -->
|
||||
<Style Selector="DataGridCell TextBlock">
|
||||
<Setter Property="FontFamily" Value="monospace" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
</Grid.Styles>
|
||||
<DataGrid Name="Table" IsReadOnly="True" CanUserSortColumns="False"
|
||||
GridLinesVisibility="All" FontFamily="monospace" FontSize="12" />
|
||||
<TextBlock Name="EmptyText" Margin="12" IsVisible="False" />
|
||||
</Grid>
|
||||
</TabItem>
|
||||
<TabItem Header="Graph">
|
||||
<local:StatisticsChart Name="Chart" />
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
</local:RefreshableWindow>
|
||||
@@ -0,0 +1,312 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// N1MM's statistics window: the contacts counted under any two of the log's
|
||||
/// fields, as a table or as a stacked column graph.
|
||||
public sealed partial class StatisticsWindow : RefreshableWindow
|
||||
{
|
||||
/// What the graph is written out at when the window is smaller than this.
|
||||
private static readonly PixelSize GraphSize = new(1200, 800);
|
||||
|
||||
private readonly AppSession session;
|
||||
|
||||
/// Set while the boxes are being filled from the settings, so filling one
|
||||
/// does not count as the operator choosing it.
|
||||
private bool loading = true;
|
||||
|
||||
public StatisticsWindow(AppSession session)
|
||||
{
|
||||
this.session = session;
|
||||
InitializeComponent();
|
||||
|
||||
Table.LoadingRow += (_, e) => e.Row.FontWeight =
|
||||
e.Row.DataContext is StatisticsRow { IsTotal: true } ? FontWeight.Bold : FontWeight.Normal;
|
||||
|
||||
RowBox.ItemsSource = StatisticsFields.Rows.Select(f => f.Name).ToList();
|
||||
ColumnBox.ItemsSource = StatisticsFields.Columns.Select(f => f.Name).ToList();
|
||||
PaletteBox.ItemsSource = ChartPalettes.Names;
|
||||
RowBox.SelectedItem = Row.Name;
|
||||
ColumnBox.SelectedItem = Column.Name;
|
||||
PaletteBox.SelectedItem = ChartPalettes.Names.Contains(session.Settings.StatsPalette)
|
||||
? session.Settings.StatsPalette
|
||||
: ChartPalettes.Names[0];
|
||||
DupesBox.IsChecked = session.Settings.StatsIncludeDupes;
|
||||
ShowTitleItem.IsChecked = session.Settings.StatsShowTitle;
|
||||
Tabs.SelectedIndex = session.Settings.StatsOnGraph ? 1 : 0;
|
||||
loading = false;
|
||||
|
||||
ShowPaletteBox();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private StatisticsField Row =>
|
||||
StatisticsFields.Find(StatisticsFields.Rows, session.Settings.StatsRow);
|
||||
|
||||
private StatisticsField Column =>
|
||||
StatisticsFields.Find(StatisticsFields.Columns, session.Settings.StatsColumn);
|
||||
|
||||
public override void Refresh()
|
||||
{
|
||||
if (session.Logging is null)
|
||||
{
|
||||
Table.Columns.Clear();
|
||||
Table.ItemsSource = Array.Empty<StatisticsRow>();
|
||||
Chart.Columns = [];
|
||||
Chart.Series = [];
|
||||
Chart.InvalidateVisual();
|
||||
Empty("no contest is open");
|
||||
return;
|
||||
}
|
||||
|
||||
StatisticsPivot pivot = StatisticsPivot.Of(
|
||||
session.Logging.Log.Qsos,
|
||||
Row,
|
||||
Column,
|
||||
session.Logging.Instance.StartDate,
|
||||
session.Settings.StatsIncludeDupes);
|
||||
Title = $"Statistics for {session.Logging.Contest.Name}"
|
||||
+ $" - {session.Logging.Instance.StartDate:yyyy-MM-dd}"
|
||||
+ $" - {Column.Name} by {Row.Name}";
|
||||
if (pivot.IsEmpty)
|
||||
{
|
||||
Table.Columns.Clear();
|
||||
Table.ItemsSource = Array.Empty<StatisticsRow>();
|
||||
Chart.Columns = [];
|
||||
Chart.Series = [];
|
||||
Chart.InvalidateVisual();
|
||||
Empty("no contacts are counted");
|
||||
return;
|
||||
}
|
||||
EmptyText.IsVisible = false;
|
||||
Table.IsVisible = true;
|
||||
FillTable(pivot);
|
||||
FillChart(pivot);
|
||||
}
|
||||
|
||||
private void Empty(string why)
|
||||
{
|
||||
EmptyText.Text = why;
|
||||
EmptyText.IsVisible = true;
|
||||
Table.IsVisible = false;
|
||||
}
|
||||
|
||||
private void FillTable(StatisticsPivot pivot)
|
||||
{
|
||||
List<string> headers =
|
||||
[
|
||||
.. pivot.Row.Headings,
|
||||
.. pivot.Columns.Select(c => c.Label.Length > 0 ? c.Label : "(none)"),
|
||||
"Tot",
|
||||
"Accum",
|
||||
];
|
||||
Table.Columns.Clear();
|
||||
for (int at = 0; at < headers.Count; at++)
|
||||
{
|
||||
Table.Columns.Add(new DataGridTextColumn
|
||||
{
|
||||
Header = headers[at],
|
||||
Width = DataGridLength.Auto,
|
||||
Binding = new Binding($"[{at}]"),
|
||||
});
|
||||
}
|
||||
Table.ItemsSource = Lines(pivot);
|
||||
}
|
||||
|
||||
private static List<StatisticsRow> Lines(StatisticsPivot pivot)
|
||||
{
|
||||
List<StatisticsRow> lines = [];
|
||||
int labels = pivot.Row.Headings.Count;
|
||||
for (int down = 0; down < pivot.Rows.Count; down++)
|
||||
{
|
||||
List<string> cells = [pivot.Rows[down].First];
|
||||
if (labels == 2)
|
||||
{
|
||||
cells.Add(pivot.Rows[down].Second);
|
||||
}
|
||||
for (int across = 0; across < pivot.Columns.Count; across++)
|
||||
{
|
||||
cells.Add(Number(pivot.Count(down, across)));
|
||||
}
|
||||
cells.Add(Number(pivot.RowTotals[down]));
|
||||
cells.Add(Number(pivot.Accumulated[down]));
|
||||
lines.Add(new StatisticsRow(cells));
|
||||
}
|
||||
|
||||
List<string> total = ["Total"];
|
||||
if (labels == 2)
|
||||
{
|
||||
total.Add("");
|
||||
}
|
||||
total.AddRange(pivot.ColumnTotals.Select(Number));
|
||||
total.Add(Number(pivot.Total));
|
||||
total.Add(Number(pivot.Total));
|
||||
lines.Add(new StatisticsRow(total, isTotal: true));
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// N1MM leaves a cell with nothing in it blank rather than printing a zero,
|
||||
/// so the shape of the operating shows through the table.
|
||||
private static string Number(int count) =>
|
||||
count == 0 ? "" : count.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
private void FillChart(StatisticsPivot pivot)
|
||||
{
|
||||
Chart.Series = [.. pivot.Columns.Select(c => c.Label)];
|
||||
Chart.Columns =
|
||||
[
|
||||
.. Enumerable.Range(0, pivot.Rows.Count).Select(down => new ChartColumn(
|
||||
Under(pivot.Rows[down]),
|
||||
[.. Enumerable.Range(0, pivot.Columns.Count).Select(across => pivot.Count(down, across))])),
|
||||
];
|
||||
Chart.Palette = ChartPalettes.Of(session.Settings.StatsPalette);
|
||||
Chart.Title = session.Logging is null
|
||||
? ""
|
||||
: $"{session.Logging.Me.Callsign} - {session.Logging.Contest.DisplayName}"
|
||||
+ $" {session.Logging.Instance.StartDate:yyyy-MM-dd}";
|
||||
Chart.ShowTitle = session.Settings.StatsShowTitle;
|
||||
Chart.InvalidateVisual();
|
||||
}
|
||||
|
||||
/// N1MM labels the graph with the last of the row's label columns, so an
|
||||
/// hour-by-hour graph reads 07, 08, 09 and not the date over and over.
|
||||
private static string Under(PivotKey key) => key.Second.Length > 0 ? key.Second : key.First;
|
||||
|
||||
private void OnRefreshClicked(object? sender, RoutedEventArgs e) => Refresh();
|
||||
|
||||
private void OnFieldChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (loading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.Save(session.Settings with
|
||||
{
|
||||
StatsRow = RowBox.SelectedItem as string ?? session.Settings.StatsRow,
|
||||
StatsColumn = ColumnBox.SelectedItem as string ?? session.Settings.StatsColumn,
|
||||
});
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnPaletteChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (loading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.Save(session.Settings with
|
||||
{
|
||||
StatsPalette = PaletteBox.SelectedItem as string ?? session.Settings.StatsPalette,
|
||||
});
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnDupesChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (loading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.Save(session.Settings with { StatsIncludeDupes = DupesBox.IsChecked == true });
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnTabChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (loading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.Save(session.Settings with { StatsOnGraph = Tabs.SelectedIndex == 1 });
|
||||
ShowPaletteBox();
|
||||
}
|
||||
|
||||
/// The palette box belongs to the graph, and printing the table to a file
|
||||
/// belongs to the table, so each is offered only on its own tab.
|
||||
private void ShowPaletteBox()
|
||||
{
|
||||
bool onGraph = Tabs.SelectedIndex == 1;
|
||||
PaletteBox.IsVisible = onGraph;
|
||||
PaletteLabel.IsVisible = onGraph;
|
||||
PrintToFileItem.IsEnabled = !onGraph;
|
||||
SaveGraphItem.IsEnabled = onGraph;
|
||||
}
|
||||
|
||||
private void OnToggleTitle(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
session.Save(session.Settings with { StatsShowTitle = ShowTitleItem.IsChecked });
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private async void OnCopyAll(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Clipboard is { } clipboard)
|
||||
{
|
||||
await clipboard.SetTextAsync(AsText(", "));
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnPrintToFile(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Print statistics to file",
|
||||
SuggestedFileName = "statistics.txt",
|
||||
SuggestedStartLocation = await Folder(session.Paths.ExportFiles),
|
||||
});
|
||||
if (file?.TryGetLocalPath() is { } path)
|
||||
{
|
||||
await File.WriteAllTextAsync(path, AsText("\t"));
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnSaveGraph(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Save the graph",
|
||||
SuggestedFileName = "statistics.png",
|
||||
SuggestedStartLocation = await Folder(session.Paths.ExportFiles),
|
||||
});
|
||||
if (file?.TryGetLocalPath() is not { } path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
PixelSize size = Chart.Bounds.Width >= 1 && Chart.Bounds.Height >= 1
|
||||
? new PixelSize((int)Chart.Bounds.Width, (int)Chart.Bounds.Height)
|
||||
: GraphSize;
|
||||
using RenderTargetBitmap bitmap = new(size);
|
||||
bitmap.Render(Chart);
|
||||
bitmap.Save(path, new PngBitmapEncoderOptions());
|
||||
}
|
||||
|
||||
private async Task<IStorageFolder?> Folder(string path) =>
|
||||
Directory.Exists(path) ? await StorageProvider.TryGetFolderFromPathAsync(path) : null;
|
||||
|
||||
/// The table as text, one line per row. N1MM's Copy All separates the
|
||||
/// values with commas and its Print to File lines the columns up.
|
||||
private string AsText(string between)
|
||||
{
|
||||
StringBuilder text = new();
|
||||
text.AppendLine(string.Join(between, Table.Columns.Select(c => c.Header)));
|
||||
if (Table.ItemsSource is IEnumerable<StatisticsRow> lines)
|
||||
{
|
||||
foreach (StatisticsRow line in lines)
|
||||
{
|
||||
text.AppendLine(string.Join(between, line.Cells));
|
||||
}
|
||||
}
|
||||
return text.ToString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user