Files
Nonemm/src/Nonemm.App/Windows/StatisticsChart.cs
T
erikbrockoandClaude Opus 5 acd22a9b5e 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
2026-09-04 07:47:30 +00:00

230 lines
8.3 KiB
C#

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;
}