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