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