DrawFiddle▶ RunEdit in Fiddle
// ── App Store stats ──────────────────────────────────────────────────────
// An App Store Connect style "App Units" page: a lifetime daily chart with a hover
// crosshair, plus the numbers table under it, regrouped by Day / Week / Month.
// Data is fake: a seeded curve shaped like a small app's first year. Refresh rolls a new seed.
// You can export (ex: OpenTk Windows) and connect real data to create a useful tool.

var accent = Color.FromArgb("#0A84FF");
var buttonBg = Color.FromArgb("#2C2C2E");

UnitsChart chart = null;
UnitsChartHover hover = null;
SkiaLabel totalLabel = null, rangeLabel = null, statusLabel = null, tableTotalLabel = null;
SkiaStack table = null;
var freqButtons = new Dictionary<Frequency, SkiaButton>();
var frequency = Frequency.Day;
var seed = 2026;
List<Bucket> days = null;

SkiaButton FreqButton(Frequency f) => new SkiaButton(f.ToString())
{
    BackgroundColor = buttonBg,
    TextColor = Colors.White,
    CornerRadius = 8,
    WidthRequest = 72,
    HeightRequest = 32,
    FontFamily = "FontText",
}
.Adapt(me => freqButtons[f] = me)
.OnTapped(me =>
{
    frequency = f;
    ShowSeries();
});

void ShowSeries()
{
    foreach (var (f, b) in freqButtons)
        b.BackgroundColor = f == frequency ? accent : buttonBg;

    var buckets = days
        .GroupBy(d => frequency switch
        {
            Frequency.Month => new DateTime(d.Start.Year, d.Start.Month, 1),
            Frequency.Week => d.Start.AddDays(-(((int)d.Start.DayOfWeek + 6) % 7)),
            _ => d.Start,
        })
        .Select(g => new Bucket(g.Key, g.Sum(d => d.Units)))
        .ToList();

    chart.SetData(buckets, frequency);
    hover.SetIndex(-1);
    table.ItemsSource = Enumerable.Reverse(buckets)   // newest first
        .Select(b => new UnitsRow(chart.FormatDate(b.Start), b.Units.ToString("N0", Fmt.En)))
        .ToList();

    var total = days.Sum(d => d.Units).ToString("N0", Fmt.En);
    tableTotalLabel.Text = total;
    totalLabel.Text = $"{total} units";
    rangeLabel.Text = days[0].Start.ToString("d MMM yyyy", Fmt.En) + " – " + days[^1].Start.ToString("d MMM yyyy", Fmt.En);
}

void Refresh()
{
    days = FakeUnits.Generate(seed++);
    ShowSeries();
    statusLabel.Text = "Updated " + DateTime.Now.ToString("HH:mm:ss", Fmt.En);
}

var separator = Color.FromArgb("#2C2C2E");
var grey = Color.FromArgb("#8E8E93");

return new SkiaLayer
{
    BackgroundColor = Color.FromArgb("#1C1C1E"),
    VerticalOptions = LayoutOptions.Fill,
    Children = new List<SkiaControl>
    {
        new SkiaGrid
        {
            VerticalOptions = LayoutOptions.Fill,
            Padding = new Thickness(24),
            RowSpacing = 16,
            Children = new List<SkiaControl>
            {
                // header: title left, controls right. Changes only on tap, so image cache.
                new SkiaGrid
                {
                    UseCache = SkiaCacheType.Image,
                    Children = new List<SkiaControl>
                    {
                        new SkiaStack
                        {
                            Spacing = 2,
                            Children = new List<SkiaControl>
                            {
                                new SkiaLabel("Your App") { FontSize = 26, FontFamily = "FontTextTitle", FontWeight = 700, TextColor = Colors.White },
                                new SkiaLabel("App Units — first-time downloads") { FontSize = 14, FontFamily = "FontText", TextColor = grey },
                            }
                        }.SetGrid(0, 0),
                        new SkiaRow
                        {
                            Spacing = 8,
                            VerticalOptions = LayoutOptions.Center,
                            Children = new List<SkiaControl>
                            {
                                FreqButton(Frequency.Day),
                                FreqButton(Frequency.Week),
                                FreqButton(Frequency.Month),
                                new SkiaButton("Refresh")
                                {
                                    BackgroundColor = accent,
                                    TextColor = Colors.White,
                                    CornerRadius = 8,
                                    WidthRequest = 96,
                                    HeightRequest = 32,
                                    FontFamily = "FontText",
                                    Margin = new Thickness(16, 0, 0, 0),
                                }
                                .OnTapped(me => Refresh()),
                            }
                        }.SetGrid(1, 0),
                    }
                }.WithColumnDefinitions("*,Auto"),

                new SkiaRow
                {
                    Spacing = 24,
                    Children = new List<SkiaControl>
                    {
                        new SkiaLabel("—") { FontSize = 20, FontFamily = "FontTextTitle", FontWeight = 600, TextColor = Colors.White }.Assign(out totalLabel),
                        new SkiaLabel("") { FontSize = 14, FontFamily = "FontText", TextColor = Color.FromArgb("#AEAEB2"), VerticalOptions = LayoutOptions.Center }.Assign(out rangeLabel),
                        new SkiaLabel("") { FontSize = 13, FontFamily = "FontText", TextColor = grey, VerticalOptions = LayoutOptions.Center }.Assign(out statusLabel),
                    }
                }.SetGrid(0, 1),

                new SkiaLayer
                {
                    VerticalOptions = LayoutOptions.Fill,
                    Children = new List<SkiaControl>
                    {
                        new UnitsChart { HorizontalOptions = LayoutOptions.Fill, VerticalOptions = LayoutOptions.Fill }.Assign(out chart),
                        new UnitsChartHover().Assign(out hover),
                    }
                }.SetGrid(0, 2),

                // numbers table: header + total, then recycled rows, newest first
                new SkiaGrid
                {
                    RowSpacing = 0,
                    Children = new List<SkiaControl>
                    {
                        new SkiaStack
                        {
                            Spacing = 0,
                            UseCache = SkiaCacheType.Image,
                            Children = new List<SkiaControl>
                            {
                                new SkiaLayer
                                {
                                    Children = new List<SkiaControl>
                                    {
                                        new SkiaLabel("DATE") { FontSize = 12, FontFamily = "FontText", TextColor = grey, Margin = new Thickness(0, 6) },
                                        new SkiaLabel("FIRST-TIME DOWNLOADS") { FontSize = 12, FontFamily = "FontText", TextColor = grey, Margin = new Thickness(0, 6), HorizontalOptions = LayoutOptions.End },
                                        new SkiaControl { BackgroundColor = separator, HeightRequest = 1, HorizontalOptions = LayoutOptions.Fill, VerticalOptions = LayoutOptions.End },
                                    }
                                },
                                new SkiaLayer
                                {
                                    Children = new List<SkiaControl>
                                    {
                                        new SkiaLabel("Total") { FontSize = 14, FontFamily = "FontText", TextColor = Colors.White, Margin = new Thickness(0, 10) },
                                        new SkiaLabel("—") { FontSize = 14, FontFamily = "FontTextTitle", FontWeight = 700, TextColor = Colors.White, Margin = new Thickness(0, 10), HorizontalOptions = LayoutOptions.End }.Assign(out tableTotalLabel),
                                        new SkiaControl { BackgroundColor = separator, HeightRequest = 1, HorizontalOptions = LayoutOptions.Fill, VerticalOptions = LayoutOptions.End },
                                    }
                                },
                            }
                        }.SetGrid(0, 0),

                        new SkiaScroll
                        {
                            VerticalOptions = LayoutOptions.Fill,
                            Content = new SkiaStack
                            {
                                ItemTemplate = new DataTemplate(() => new UnitsRowCell()),
                                RecyclingTemplate = RecyclingTemplate.Enabled,
                                MeasureItemsStrategy = MeasuringStrategy.MeasureFirst,
                                Spacing = 0,
                            }.Assign(out table),
                        }.SetGrid(0, 1),
                    }
                }.WithRowDefinitions("Auto,*").SetGrid(0, 3),
            }
        }
        .WithRowDefinitions("Auto,Auto,3*,2*")
        .WithGestures((me, args, apply) =>
        {
            // pointer left the chart area: drop the crosshair
            if (args.Type == TouchActionResult.Pointer && !hover.DrawingRect.Contains(args.Event.Location.X, args.Event.Location.Y))
                hover.SetIndex(-1);
            return null;
        })
        .Initialize(me =>
        {
            hover.Chart = chart;
            Refresh();
        }),
    }
};

public enum Frequency { Day, Week, Month }

public record Bucket(DateTime Start, int Units);

public record UnitsRow(string Date, string Units);

public static class Fmt
{
    // fixed formatting: the snippet runs in the viewer's browser locale
    public static readonly System.Globalization.CultureInfo En = System.Globalization.CultureInfo.InvariantCulture;
}

/// <summary>A small app's first year: quiet launch, slow ramp, a busy summer, a drop in late August.</summary>
public static class FakeUnits
{
    public static List<Bucket> Generate(int seed)
    {
        var rnd = new Random(seed);
        var start = new DateTime(2025, 9, 4);
        var list = new List<Bucket>();
        for (var d = start; d <= new DateTime(2026, 9, 23); d = d.AddDays(1))
        {
            double v;
            if (d == start) v = 12;                                                    // launch day
            else if (d < new DateTime(2026, 1, 1)) v = rnd.NextDouble() * rnd.NextDouble() * 4;
            else if (d < new DateTime(2026, 4, 1)) v = 1 + 9 * (d - new DateTime(2026, 1, 1)).TotalDays / 90 + (rnd.NextDouble() - 0.5) * 4;
            else if (d < new DateTime(2026, 8, 21))
            {
                var t = (d - new DateTime(2026, 4, 1)).TotalDays / 142;                // 0..1 across the summer
                var peak = Math.Exp(-Math.Pow((d - new DateTime(2026, 8, 5)).TotalDays / 6, 2)) * 12;
                v = 12 + 6 * t + peak + (rnd.NextDouble() - 0.5) * 9;
            }
            else if (d < new DateTime(2026, 9, 1)) v = 16 - (d - new DateTime(2026, 8, 21)).TotalDays * 1.1 + (rnd.NextDouble() - 0.5) * 3;
            else v = 4 + rnd.NextDouble() * 9;
            list.Add(new Bucket(d, Math.Max(0, (int)Math.Round(v))));
        }
        return list;
    }
}

/// <summary>
/// Line + area chart of unit buckets. Static between data changes, so it is Operations-cached;
/// the hover crosshair lives in <see cref="UnitsChartHover"/> on top, uncached.
/// </summary>
public class UnitsChart : SkiaControl
{
    public static readonly SKColor Line = SKColor.Parse("#0A84FF");
    static readonly SKColor Grid = SKColor.Parse("#2C2C2E");
    static readonly SKColor Text = SKColor.Parse("#8E8E93");

    public UnitsChart()
    {
        UseCache = SkiaCacheType.Operations;
    }

    public IReadOnlyList<Bucket> Buckets { get; private set; } = new List<Bucket>();
    public Frequency Frequency { get; private set; }
    public int MaxY { get; private set; } = 1;

    public void SetData(IReadOnlyList<Bucket> buckets, Frequency frequency)
    {
        Buckets = buckets;
        Frequency = frequency;
        MaxY = NiceMax(buckets.Count == 0 ? 1 : buckets.Max(b => b.Units));
        Update();
    }

    /// <summary>Plot area inside the control, in pixels.</summary>
    public static SKRect PlotRect(SKRect dest, float scale) =>
        new(dest.Left + 56 * scale, dest.Top + 12 * scale, dest.Right - 16 * scale, dest.Bottom - 32 * scale);

    public SKPoint PointAt(SKRect plot, int i)
    {
        var x = Buckets.Count < 2 ? plot.MidX : plot.Left + plot.Width * i / (Buckets.Count - 1);
        var y = plot.Bottom - plot.Height * Buckets[i].Units / MaxY;
        return new SKPoint(x, y);
    }

    public int IndexAt(SKRect plot, float x)
    {
        if (Buckets.Count == 0) return -1;
        var i = (int)MathF.Round((x - plot.Left) / plot.Width * (Buckets.Count - 1));
        return Math.Clamp(i, 0, Buckets.Count - 1);
    }

    public string FormatDate(DateTime d) => Frequency switch
    {
        Frequency.Month => d.ToString("MMM yyyy", Fmt.En),
        Frequency.Week => "week of " + d.ToString("d MMM yyyy", Fmt.En),
        _ => d.ToString("ddd d MMM yyyy", Fmt.En),
    };

    static int NiceMax(int v)
    {
        if (v <= 4) return 4;
        var mag = Math.Pow(10, Math.Floor(Math.Log10(v)));
        foreach (var m in new[] { 1, 2, 2.5, 5, 10 })
            if (m * mag >= v) return (int)Math.Ceiling(m * mag / 4) * 4;
        return v;
    }

    protected override void Paint(DrawingContext ctx)
    {
        base.Paint(ctx);

        var canvas = ctx.Context.Canvas;
        var scale = ctx.Scale;
        var plot = PlotRect(ctx.Destination, scale);

        using var font = new SKFont(SkiaFontManager.Instance.GetFont("FontText"), 12 * scale);
        using var textPaint = new SKPaint { Color = Text, IsAntialias = true };
        using var gridPaint = new SKPaint { Color = Grid, StrokeWidth = 1, IsAntialias = false };

        // Y grid: 4 steps
        for (var s = 0; s <= 4; s++)
        {
            var y = plot.Bottom - plot.Height * s / 4f;
            canvas.DrawLine(plot.Left, y, plot.Right, y, gridPaint);
            canvas.DrawText((MaxY * s / 4).ToString("N0", Fmt.En), plot.Left - 8 * scale, y + 4 * scale, SKTextAlign.Right, font, textPaint);
        }

        if (Buckets.Count == 0)
            return;

        // X labels: up to 8 evenly spaced, fewer when the plot is too narrow for them
        var labels = Math.Clamp((int)(plot.Width / (90 * scale)), 2, 8);
        labels = Math.Min(labels, Buckets.Count);
        for (var l = 0; l < labels; l++)
        {
            var i = labels == 1 ? 0 : (int)Math.Round((Buckets.Count - 1) * l / (double)(labels - 1));
            var x = PointAt(plot, i).X;
            var align = l == 0 ? SKTextAlign.Left : l == labels - 1 ? SKTextAlign.Right : SKTextAlign.Center;
            var text = Buckets[i].Start.ToString(Frequency == Frequency.Month ? "MMM yy" : "d MMM yy", Fmt.En);
            canvas.DrawText(text, x, plot.Bottom + 20 * scale, align, font, textPaint);
        }

        using var path = new SKPath();
        path.MoveTo(PointAt(plot, 0));
        for (var i = 1; i < Buckets.Count; i++)
            path.LineTo(PointAt(plot, i));

        using var area = new SKPath(path);
        area.LineTo(PointAt(plot, Buckets.Count - 1).X, plot.Bottom);
        area.LineTo(PointAt(plot, 0).X, plot.Bottom);
        area.Close();

        using var fill = new SKPaint
        {
            IsAntialias = true,
            Shader = SKShader.CreateLinearGradient(new SKPoint(0, plot.Top), new SKPoint(0, plot.Bottom),
                new[] { Line.WithAlpha(110), Line.WithAlpha(8) }, SKShaderTileMode.Clamp),
        };
        canvas.DrawPath(area, fill);

        using var stroke = new SKPaint { Color = Line, IsAntialias = true, IsStroke = true, StrokeWidth = 2 * scale, StrokeJoin = SKStrokeJoin.Round };
        canvas.DrawPath(path, stroke);
    }
}

/// <summary>Crosshair + tooltip following the pointer over <see cref="UnitsChart"/>. Repaints per pointer move, so no cache.</summary>
public class UnitsChartHover : SkiaControl
{
    int _index = -1;

    public UnitsChart Chart { get; set; }

    public UnitsChartHover()
    {
        HorizontalOptions = LayoutOptions.Fill;
        VerticalOptions = LayoutOptions.Fill;
    }

    public void SetIndex(int index)
    {
        if (index == _index) return;
        _index = index;
        Update();
    }

    public override ISkiaGestureListener ProcessGestures(SkiaGesturesParameters args, GestureEventProcessingInfo apply)
    {
        if (Chart != null && args.Type is TouchActionResult.Pointer or TouchActionResult.Down or TouchActionResult.Panning)
        {
            var plot = UnitsChart.PlotRect(DrawingRect, RenderingScale);
            var x = args.Event.Location.X;
            SetIndex(x < plot.Left || x > plot.Right ? -1 : Chart.IndexAt(plot, x));
        }
        return null;
    }

    protected override void Paint(DrawingContext ctx)
    {
        base.Paint(ctx);
        if (Chart == null || _index < 0 || _index >= Chart.Buckets.Count)
            return;

        var canvas = ctx.Context.Canvas;
        var scale = ctx.Scale;
        var plot = UnitsChart.PlotRect(ctx.Destination, scale);
        var p = Chart.PointAt(plot, _index);
        var bucket = Chart.Buckets[_index];

        using var linePaint = new SKPaint { Color = SKColors.White.WithAlpha(70), StrokeWidth = 1 };
        canvas.DrawLine(p.X, plot.Top, p.X, plot.Bottom, linePaint);
        using var dot = new SKPaint { Color = UnitsChart.Line, IsAntialias = true };
        canvas.DrawCircle(p, 5 * scale, dot);

        using var font = new SKFont(SkiaFontManager.Instance.GetFont("FontText"), 13 * scale);
        using var bold = new SKFont(SkiaFontManager.Instance.GetFont("FontTextTitle"), 16 * scale);
        var line1 = bucket.Units.ToString("N0", Fmt.En) + " units";
        var line2 = Chart.FormatDate(bucket.Start);
        var w = Math.Max(bold.MeasureText(line1), font.MeasureText(line2)) + 20 * scale;
        var h = 50 * scale;
        var left = p.X + 12 * scale + w > plot.Right ? p.X - 12 * scale - w : p.X + 12 * scale;
        var box = SKRect.Create(left, plot.Top, w, h);

        using var bg = new SKPaint { Color = SKColor.Parse("#EE2C2C2E"), IsAntialias = true };
        canvas.DrawRoundRect(box, 8 * scale, 8 * scale, bg);
        using var white = new SKPaint { Color = SKColors.White, IsAntialias = true };
        using var greyPaint = new SKPaint { Color = SKColor.Parse("#AEAEB2"), IsAntialias = true };
        canvas.DrawText(line1, box.Left + 10 * scale, box.Top + 21 * scale, SKTextAlign.Left, bold, white);
        canvas.DrawText(line2, box.Left + 10 * scale, box.Top + 40 * scale, SKTextAlign.Left, font, greyPaint);
    }
}

/// <summary>Recycled table row: date left, units right, hairline below. Uniform height (MeasureFirst).</summary>
public class UnitsRowCell : SkiaDynamicDrawnCell
{
    SkiaLabel _date, _units;

    public UnitsRowCell()
    {
        HorizontalOptions = LayoutOptions.Fill;
        UseCache = SkiaCacheType.GPU; // uniform rows: blit per row while scrolling
        Children = new List<SkiaControl>
        {
            new SkiaLabel { FontSize = 14, FontFamily = "FontText", TextColor = Colors.White, Margin = new Thickness(0, 10) }.Assign(out _date),
            new SkiaLabel { FontSize = 14, FontFamily = "FontTextTitle", TextColor = Colors.White, FontWeight = 600, Margin = new Thickness(0, 10), HorizontalOptions = LayoutOptions.End }.Assign(out _units),
            new SkiaControl { BackgroundColor = Color.FromArgb("#2C2C2E"), HeightRequest = 1, HorizontalOptions = LayoutOptions.Fill, VerticalOptions = LayoutOptions.End },
        };
    }

    protected override void SetContent(object ctx)
    {
        base.SetContent(ctx);
        if (ctx is UnitsRow row)
        {
            _date.Text = row.Date;
            _units.Text = row.Units;
        }
    }
}