diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 719e72e..60ee962 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -20,6 +20,7 @@ active one as application resources, so switching theme repaints every component | `Destructive`, `Success`, `Warning`, `Info` (+ `*Foreground`) | Status variants (badge, alert, progress, labels) | | `Border`, `Input`, `Ring` | Dividers, field borders, focus ring | | `Overlay` | Dialog / drawer / sheet backdrop | +| `Chart1` … `Chart5` | Chart series, in order (ShellUI's `--chart-1` … `--chart-5`) | **Use the tokens in your own XAML** — each token is published as a Color (`ShellUI`) and a Brush (`ShellUIBrush`): @@ -112,6 +113,7 @@ Same names, properties and variants as MAUI, except: | Carousel | Swipe, arrows, dots | Drag with the mouse or touch; the arrows are tab stops and Left / Right move while one has focus; with `AspectRatio="0"` set `Height` | | Stepper | Shows the active step's view | Every step stays in the tree and only the active one is visible; reached steps are tab stops (Enter / Space) | | Pagination | Labels hidden on phones | `ShowLabels` defaults to true; every page and arrow is a tab stop (Enter / Space) | +| Charts | Drawn with `GraphicsView`; on touch a tap or a drag shows the tooltip; `HeightRequest` | Drawn in `Render`, in the control's own font; `Height`; `ChartSeries.Color` and the token colors are Avalonia `Color`s (`Color.Parse("#E76E50")`) | | ImageViewer | `Source` is an `ImageSource`; `Aspect`; pinch zooms | `Source` is an `IImage` (a resource path such as `/Assets/photo.jpg`); `Stretch` instead of `Aspect`; the wheel and the + / - / 0 keys zoom; the thumbnail and the controls are tab stops | | PrevNextNav | Tap a card | The cards are also tab stops (Enter / Space) | | ChatInput | The send button sends | Also Enter sends and Shift+Enter starts a new line | @@ -1468,6 +1470,121 @@ Bordered callout with an icon. **Properties:** `Title`, `Message`, `Variant` (De --- +## Charts + +Bar, line, area, pie, donut, radar and radial charts. They are drawn by the components +themselves (MAUI's `GraphicsView`, Avalonia's `Render`), so they need no charting package, and +they take their colors from the theme: `Chart1` to `Chart5`, in order. Every chart has a legend, +a tooltip under the pointer (or the finger) and a grow-in animation. + +```bash +shellui-native add bar-chart line-chart area-chart pie-chart radar-chart radial-chart +``` + +Each chart installs `chart`, the shared core. The chart colors are new theme tokens: a project +whose `shell` came from an older CLI version needs `shellui-native update shell` first. + +### Data + +A chart's children are its series; `Labels` names the categories (or the slices of a pie). In +XAML both are comma-separated lists. + +**ChartSeries:** `Name` (shown in the legend and tooltip), `Values`, `Color` (replaces the +theme color for that series) + +```xml + + + + +``` + +From code: + +```csharp +Sales.Labels = new ChartLabels(months); +Sales.Series.Add(new ChartSeries { Name = "Desktop", Values = new ChartValues(desktopVisits) }); +``` + +Adding or removing a series, or setting a series' `Values`, `Name` or `Color`, redraws the chart. +After changing numbers inside an existing `Values` list, call `Refresh()`. + +**Every chart:** `Labels`, `ShowLegend` (true), `ShowTooltip` (true), `IsAnimated` (true), +`ValueFormat` (a .NET number format; `#,0.##`), `EmptyText` ("No data"), `Description` (for +screen readers) · **Methods:** `Refresh()`, `PlayAnimation()` + +**Bar, line and area charts also have:** `ShowGrid`, `ShowXAxis`, `ShowYAxis` (all true), +`Minimum`, `Maximum` (the ends of the value axis; by default they fit the data and include zero) + +Give a chart a height (`HeightRequest`, or `Height` on Avalonia). + +### Bar Chart +One bar per series for each category. **Properties:** `IsStacked`, `IsHorizontal` (bars lie +down; negative values go the other way), `CornerRadius` (4), `MaxBarSize` (56) + +```xml + ... +``` + +### Line Chart +One line per series, from edge to edge of the plot. **Properties:** `IsSmooth` (true; false +joins the points with straight segments), `ShowDots`, `StrokeWidth` (2) + +### Area Chart +A line per series with the space under it filled. **Properties:** `IsSmooth` (true), +`IsStacked`, `FillOpacity` (0.4), `StrokeWidth` (2) + +### Pie Chart and Donut Chart +The first series' values as slices, clockwise from twelve o'clock, named by `Labels` and colored +from the chart colors in order. The tooltip shows a slice's value and its share. `DonutChart` is +a pie with a hole. **Properties:** `InnerRadius` (0 for a pie, 0.6 for a donut), `Gap` (degrees +between slices; 1), `ShowLabels` (the value outside each slice), `CenterValue`, `CenterLabel` +(text in a donut's hole) + +```xml + + + +``` + +### Radar Chart +One spoke per category around a ringed grid, and a filled shape per series. It needs at least +three categories. **Properties:** `Maximum` (the value at the outer ring), `FillOpacity` (0.5), +`ShowDots` + +### Radial Chart +The first series' values as concentric rings on a muted track, each filling clockwise towards +`Maximum`; the first value is the outer ring. **Properties:** `Maximum` (a full ring; by default +the largest value), `InnerRadius` (0.35), `IsRounded` (true), `CenterValue`, `CenterLabel` + +```xml + + + +``` + +### Colors +The chart colors are theme tokens, so they follow light and dark mode and can be replaced like +any other token, before the first page loads: + +```csharp +ShellTheme.Light[ShellToken.Chart1] = Color.FromArgb("#E76E50"); +ShellTheme.Dark[ShellToken.Chart1] = Color.FromArgb("#2662D9"); +``` + +Past the fifth series (or slice) the colors repeat, fainter. A series' own `Color` wins over the +theme. + +### What the charts don't do +Zooming and panning, mixing bars and lines in one chart, click events on a bar or slice, and very +large data sets (every point is drawn). For those, a charting library such as LiveCharts sits +fine next to ShellUI Native: color it from `ShellTheme.Get(ShellToken.Chart1)`. + +To draw a chart of your own, derive from `ChartBase` and draw in `DrawChart` with the +`IChartCanvas` it hands you; the legend, tooltip and animation come with the base class. + +--- + ## Overlay Components ### Dialog, Drawer, Sheet diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md index d3163cc..e63155d 100644 --- a/docs/COMPONENTS_ROADMAP.md +++ b/docs/COMPONENTS_ROADMAP.md @@ -4,7 +4,7 @@ Prioritized list of components to create for ShellUI Native, aligned with [Shell Last revised: **2026-10-11** (second prerelease, `0.1.0-alpha.2`). -**Status at a glance:** P0–P6 ✅ done · P7 done except chart · `0.1.0-alpha.2`: 63 families, 94 CLI targets, each for MAUI and Avalonia · since then: button-group, input-group, menubar, command, navigation-menu, data-table, chat, form, image-viewer, prev-next-nav. +**Status at a glance:** P0–P7 ✅ done · `0.1.0-alpha.2`: 63 families, 94 CLI targets, each for MAUI and Avalonia · since then: button-group, input-group, menubar, command, navigation-menu, data-table, chat, form, image-viewer, prev-next-nav, charts. --- @@ -202,7 +202,7 @@ Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIc ### P7 — Optional (Charts, Pickers, etc.) | Component | Priority | Notes | |-----------|----------|-------| -| chart (line, bar, pie, area) | P7 | Consider external chart lib integration | +| **chart** ✅ | P7 | Done 2026-10-13, MAUI and Avalonia — drawn natively with no library: bar, line, area, pie / donut, radar and radial charts on a shared `chart` core (axes, legend, tooltip, animation), colored from five new theme tokens | | combobox ✅ | P7 | Done 2026-10-04 — Select with a filter field, Enter picks the first match | | **file-upload** ✅ | P7 | Done 2026-10-11, MAUI and Avalonia — drop zone, platform file picker, list of picked files | | input-otp ✅ | P7 | Done 2026-10-04 — slots over one hidden field (paste / autofill / numeric keyboard) | @@ -297,8 +297,9 @@ resizable, navbar, sidebar, date-range-picker and file-upload on both platforms input-group ✅ and menubar ✅ (2026-10-11), then command ✅ (with a command palette) and navigation-menu ✅, then data-table ✅ (client-side filter, sort, selection and pages; 2026-10-12), then chat ✅ and form ✅, then image-viewer ✅ and prev-next-nav ✅ (2026-10-13; -73 families, 104 CLI targets). +73 families, 104 CLI targets), then the charts ✅ (bar, line, area, pie / donut, radar, +radial; 80 families, 111 CLI targets). -**Backlog:** chart (P7). qr-code waits, like the icon switch, for NuGet package dependencies in the +**Backlog:** qr-code waits, like the icon switch, for NuGet package dependencies in the CLI: it needs a QR encoder. Not in the native data-table yet: row actions and server-side paging. diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index 12ad79d..b047cc8 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -20,8 +20,9 @@ main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avaloni layout, navigation and pickers on both platforms (PR #14) merged release 0.1.0-alpha.2 (PR #15), CI action bumps (PR #16), button-group, input-group and menubar (PR #17), command and navigation-menu (PR #18), - data-table (PR #19), chat and form (PR #20) merged - └─ feat/image-viewer-prev-next ← image-viewer and prev-next-nav (active) + data-table (PR #19), chat and form (PR #20), image-viewer and prev-next-nav (PR #21) + merged + └─ feat/charts ← charts drawn natively, no library (active) └─ feat/winui ← Phase 3 (conditional) ``` @@ -38,8 +39,8 @@ main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avaloni | Branch | Base | Status | Purpose | |--------|------|--------|---------| -| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17–#20 merged since | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content | -| `feat/image-viewer-prev-next` | `main` | **Active** | image-viewer and prev-next-nav for MAUI and Avalonia (107 registry entries, 104 CLI targets, 73 families) | +| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17–#21 merged since | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content | +| `feat/charts` | `main` | **Active** | chart core plus bar, line, area, pie / donut, radar and radial charts for MAUI and Avalonia (114 registry entries, 111 CLI targets, 80 families) | --- @@ -539,6 +540,14 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase. - [x] image-viewer and prev-next-nav (`feat/image-viewer-prev-next`): ImageViewer is a `ShellOverlayHost` whose trigger is its own thumbnail; zoom scales the image, no panning. With these, the Blazor set is ported except the chart family and qr-code +- [x] Charts (`feat/charts`), drawn natively instead of wrapping a library (decided + 2026-10-13: ShellUI on Blazor wraps ApexCharts; on native a library would need NuGet + dependencies in the CLI, would add SkiaSharp, and LiveCharts' Avalonia package targets + Avalonia 11). `chart` holds the series model, the `IChartCanvas` abstraction (lines, + polylines, polygons and text only; curves and arcs are turned into points), axes, legend, + tooltip and animation; bar-chart, line-chart, area-chart, pie-chart (with DonutChart), + radar-chart and radial-chart draw on it. `ShellToken.Chart1`–`Chart5` are new, from ShellUI's + `--chart-1`–`--chart-5`, so an older `shell` needs `shellui-native update shell` - [ ] Replace the generated `Icon.cs` with the `ShellIcons.Maui` / `ShellIcons.Avalonia` packages once they are on NuGet. Their names already match (`IconName`, `Icon`, `Kind` on Avalonia), so components change little: token tinting binds the package's `Color` (MAUI) or `Foreground` diff --git a/examples/Avalonia.Demo/Components/UI/AreaChart.cs b/examples/Avalonia.Demo/Components/UI/AreaChart.cs new file mode 100644 index 0000000..93e247b --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/AreaChart.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Area chart — a line per series with the space under it filled at 40%. Stacked, each +// series sits on the ones before it. +// +// +// +// +public class AreaChart : CartesianChart +{ + // Curves the line through its points; false joins them with straight segments. + public static readonly StyledProperty IsSmoothProperty = + AvaloniaProperty.Register(nameof(IsSmooth), true); + + // Stacks the series instead of overlapping them. + public static readonly StyledProperty IsStackedProperty = + AvaloniaProperty.Register(nameof(IsStacked)); + + public static readonly StyledProperty FillOpacityProperty = + AvaloniaProperty.Register(nameof(FillOpacity), 0.4); + + public static readonly StyledProperty StrokeWidthProperty = + AvaloniaProperty.Register(nameof(StrokeWidth), 2d); + + static AreaChart() + { + AffectsRender(IsSmoothProperty, IsStackedProperty, FillOpacityProperty, StrokeWidthProperty); + } + + public bool IsSmooth + { + get => GetValue(IsSmoothProperty); + set => SetValue(IsSmoothProperty, value); + } + + public bool IsStacked + { + get => GetValue(IsStackedProperty); + set => SetValue(IsStackedProperty, value); + } + + public double FillOpacity + { + get => GetValue(FillOpacityProperty); + set => SetValue(FillOpacityProperty, value); + } + + public double StrokeWidth + { + get => GetValue(StrokeWidthProperty); + set => SetValue(StrokeWidthProperty, value); + } + + protected override void DrawChart(IChartCanvas canvas, Rect area) + { + var count = CategoryCount; + double min = 0, max = 0; + for (var i = 0; i < count; i++) + { + var values = Series.Select(s => ValueAt(s, i)).ToList(); + max = Math.Max(max, IsStacked ? values.Where(v => v > 0).Sum() : values.Max()); + min = Math.Min(min, IsStacked ? values.Where(v => v < 0).Sum() : values.Min()); + } + + var frame = DrawAxes(canvas, area, min, max, bands: false); + var plot = frame.Plot; + var hovered = HoveredIndex; + if (hovered >= 0) + { + // The cursor: a hairline through the hovered category. + var x = Math.Round(frame.Category(hovered)) + 0.5; + canvas.Line(new Point(x, plot.Top), new Point(x, plot.Bottom), ShellTheme.Get(ShellToken.Border), 1); + } + + // In a stack each series sits on the running total of the ones before it. + var stack = new double[count]; + var dots = new List<(Point At, Color Color)>(); + for (var s = 0; s < Series.Count; s++) + { + var color = SeriesColor(s); + var top = new List(); + var bottom = new List(); + for (var i = 0; i < count; i++) + { + var from = IsStacked ? stack[i] : 0; + var to = from + ValueAt(Series[s], i) * Progress; + if (IsStacked) stack[i] = to; + top.Add(new Point(frame.Category(i), frame.Value(to))); + bottom.Add(new Point(frame.Category(i), frame.Value(from))); + } + + var upper = IsSmooth ? ChartMath.Smooth(top) : top; + var lower = IsSmooth ? ChartMath.Smooth(bottom) : bottom; + var shape = new List(upper); + shape.AddRange(Enumerable.Reverse(lower)); + canvas.Polygon(shape, ChartColor.Fade(color, FillOpacity)); + if (upper.Count > 1) canvas.Polyline(upper, color, StrokeWidth); + if (hovered >= 0 && hovered < top.Count) dots.Add((top[hovered], color)); + } + + foreach (var dot in dots) + { + canvas.Polygon(ChartMath.Circle(dot.At, StrokeWidth + 4), ShellTheme.Get(ShellToken.Background)); + canvas.Polygon(ChartMath.Circle(dot.At, StrokeWidth + 2.5), dot.Color); + } + } +} diff --git a/examples/Avalonia.Demo/Components/UI/BarChart.cs b/examples/Avalonia.Demo/Components/UI/BarChart.cs new file mode 100644 index 0000000..b68abb7 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/BarChart.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Bar chart — one bar per series for each category, side by side or stacked, upright or +// lying down. Bars are rounded at their outer end, and the hovered category gets a muted band +// and a tooltip. +// +// +// +// +public class BarChart : CartesianChart +{ + // Stacks the series on top of each other instead of side by side. + public static readonly StyledProperty IsStackedProperty = + AvaloniaProperty.Register(nameof(IsStacked)); + + // Lays the bars down: categories run down the side, values along the bottom. + public static readonly StyledProperty IsHorizontalProperty = + AvaloniaProperty.Register(nameof(IsHorizontal)); + + public static readonly StyledProperty CornerRadiusProperty = + AvaloniaProperty.Register(nameof(CornerRadius), 4d); + + // The thickest a bar gets, however much room there is. + public static readonly StyledProperty MaxBarSizeProperty = + AvaloniaProperty.Register(nameof(MaxBarSize), 56d); + + static BarChart() + { + AffectsRender(IsStackedProperty, IsHorizontalProperty, CornerRadiusProperty, MaxBarSizeProperty); + } + + public bool IsStacked + { + get => GetValue(IsStackedProperty); + set => SetValue(IsStackedProperty, value); + } + + public bool IsHorizontal + { + get => GetValue(IsHorizontalProperty); + set => SetValue(IsHorizontalProperty, value); + } + + public double CornerRadius + { + get => GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + + public double MaxBarSize + { + get => GetValue(MaxBarSizeProperty); + set => SetValue(MaxBarSizeProperty, value); + } + + protected override void DrawChart(IChartCanvas canvas, Rect area) + { + var count = CategoryCount; + var series = Series; + double min = 0, max = 0; + for (var i = 0; i < count; i++) + { + double positive = 0, negative = 0; + foreach (var entry in series) + { + var value = ValueAt(entry, i); + if (!IsStacked) + { + positive = Math.Max(positive, value); + negative = Math.Min(negative, value); + } + else if (value >= 0) positive += value; + else negative += value; + } + max = Math.Max(max, positive); + min = Math.Min(min, negative); + } + + var frame = DrawAxes(canvas, area, min, max, bands: true, horizontal: IsHorizontal); + var plot = frame.Plot; + + // The cursor: a muted band behind the hovered category. + if (HoveredIndex >= 0) + { + var middle = frame.Category(HoveredIndex); + var band = IsHorizontal + ? new Rect(plot.Left, middle - frame.Band / 2, plot.Width, frame.Band) + : new Rect(middle - frame.Band / 2, plot.Top, frame.Band, plot.Height); + canvas.Polygon(ChartMath.RoundRect(band, 4, 4, 4, 4), ChartColor.Fade(ShellTheme.Get(ShellToken.Muted), 0.6)); + } + + const double gap = 4; + var slots = IsStacked ? 1 : Math.Max(1, series.Count); + var thickness = Math.Max(1, Math.Min(MaxBarSize, (frame.Band * 0.8 - gap * (slots - 1)) / slots)); + var group = thickness * slots + gap * (slots - 1); + for (var i = 0; i < count; i++) + { + var start = frame.Category(i) - group / 2; + double positive = 0, negative = 0; + for (var s = 0; s < series.Count; s++) + { + var full = ValueAt(series[s], i); + var value = full * Progress; + if (full == 0) continue; + var from = !IsStacked ? 0 : full >= 0 ? positive : negative; + var to = from + value; + if (IsStacked && full >= 0) positive = to; + else if (IsStacked) negative = to; + + // Only the outer end of a bar, or of a whole stack, is rounded. + var outer = !IsStacked || !series.Skip(s + 1).Any(next => ValueAt(next, i) != 0 && ValueAt(next, i) >= 0 == full >= 0); + var radius = outer ? CornerRadius : 0; + var offset = start + (IsStacked ? 0 : s * (thickness + gap)); + var a = frame.Value(from); + var b = frame.Value(to); + if (IsHorizontal) + { + var rect = new Rect(Math.Min(a, b), offset, Math.Abs(b - a), thickness); + canvas.Polygon(full >= 0 ? ChartMath.RoundRect(rect, 0, radius, radius, 0) : ChartMath.RoundRect(rect, radius, 0, 0, radius), SeriesColor(s)); + } + else + { + var rect = new Rect(offset, Math.Min(a, b), thickness, Math.Abs(b - a)); + canvas.Polygon(full >= 0 ? ChartMath.RoundRect(rect, radius, radius, 0, 0) : ChartMath.RoundRect(rect, 0, 0, radius, radius), SeriesColor(s)); + } + } + } + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Chart.cs b/examples/Avalonia.Demo/Components/UI/Chart.cs new file mode 100644 index 0000000..1f053c6 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Chart.cs @@ -0,0 +1,806 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Media.Immutable; +using Avalonia.Metadata; +using Avalonia.Threading; + +namespace AvaloniaDemo.Components.UI; + +// The shared core of the charts (bar-chart, line-chart, area-chart, pie-chart, radar-chart, +// radial-chart): the series model, the axes, legend and tooltip, the grow-in animation, and the +// canvas they draw on. Charts are drawn in Render, so they need no package, and they take their +// colors from the theme (ShellToken.Chart1 to Chart5). +// To make a chart of your own, derive from ChartBase and draw in DrawChart. + +public enum ChartAlign { Start, Center, End } + +// The drawing calls a chart needs. Angles are in degrees, clockwise, 0 at three o'clock. +public interface IChartCanvas +{ + void Line(Point from, Point to, Color color, double width, bool dashed = false); + void Polyline(IReadOnlyList points, Color color, double width, bool closed = false); + void Polygon(IReadOnlyList points, Color color); + void Text(string text, Point at, double size, Color color, ChartAlign horizontal = ChartAlign.Start, ChartAlign vertical = ChartAlign.Start, bool bold = false); + Size Measure(string text, double size, bool bold = false); +} + +// A list of numbers that XAML can write as "186, 305, 237". +[System.ComponentModel.TypeConverter(typeof(ChartValuesConverter))] +public class ChartValues : List +{ + public ChartValues() + { + } + + public ChartValues(IEnumerable values) : base(values) + { + } + + public static implicit operator ChartValues(double[] values) => new(values); +} + +public class ChartValuesConverter : System.ComponentModel.TypeConverter +{ + public override bool CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, Type sourceType) => + sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); + + public override object? ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, CultureInfo? culture, object value) => + value is string text + ? new ChartValues(text.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries).Select(part => double.Parse(part, CultureInfo.InvariantCulture))) + : base.ConvertFrom(context, culture, value); +} + +// A list of category names that XAML can write as "Jan, Feb, Mar". +[System.ComponentModel.TypeConverter(typeof(ChartLabelsConverter))] +public class ChartLabels : List +{ + public ChartLabels() + { + } + + public ChartLabels(IEnumerable labels) : base(labels) + { + } + + public static implicit operator ChartLabels(string[] labels) => new(labels); +} + +public class ChartLabelsConverter : System.ComponentModel.TypeConverter +{ + public override bool CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, Type sourceType) => + sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); + + public override object? ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, CultureInfo? culture, object value) => + value is string text + ? new ChartLabels(text.Split(',').Select(part => part.Trim())) + : base.ConvertFrom(context, culture, value); +} + +// One named set of values. The chart redraws when a property is set; after changing the numbers +// inside Values, call the chart's Refresh(). +public class ChartSeries +{ + private string _name = string.Empty; + private ChartValues _values = new(); + private Color? _color; + + public string Name + { + get => _name; + set + { + _name = value ?? string.Empty; + Changed?.Invoke(); + } + } + + public ChartValues Values + { + get => _values; + set + { + _values = value ?? new ChartValues(); + Changed?.Invoke(); + } + } + + // Replaces the theme's chart color for this series. + public Color? Color + { + get => _color; + set + { + _color = value; + Changed?.Invoke(); + } + } + + internal Action? Changed { get; set; } +} + +// Geometry shared by the charts. Curves and arcs come back as points, so a canvas only has to +// draw straight segments. +public static class ChartMath +{ + public static Point Polar(Point center, double radius, double degrees) + { + var radians = degrees * Math.PI / 180; + return new Point(center.X + radius * Math.Cos(radians), center.Y + radius * Math.Sin(radians)); + } + + public static List Arc(Point center, double radius, double startDegrees, double sweepDegrees) + { + var steps = Math.Max(1, (int)Math.Ceiling(Math.Abs(sweepDegrees) / 3)); + var points = new List(steps + 1); + for (var i = 0; i <= steps; i++) points.Add(Polar(center, radius, startDegrees + sweepDegrees * i / steps)); + return points; + } + + // A pie slice, or a ring segment when inner is above zero. + public static List Sector(Point center, double inner, double outer, double startDegrees, double sweepDegrees) + { + var points = Arc(center, outer, startDegrees, sweepDegrees); + if (inner <= 0) points.Add(center); + else points.AddRange(Arc(center, inner, startDegrees + sweepDegrees, -sweepDegrees)); + return points; + } + + public static List Circle(Point center, double radius) => Arc(center, radius, 0, 360); + + // A rectangle with its own radius on each corner, clockwise from the top left. + public static List RoundRect(Rect rect, double topLeft, double topRight, double bottomRight, double bottomLeft) + { + var limit = Math.Min(rect.Width, rect.Height) / 2; + var points = new List(); + Corner(points, rect.Left, rect.Top, 1, 1, Math.Min(topLeft, limit), 180); + Corner(points, rect.Right, rect.Top, -1, 1, Math.Min(topRight, limit), 270); + Corner(points, rect.Right, rect.Bottom, -1, -1, Math.Min(bottomRight, limit), 0); + Corner(points, rect.Left, rect.Bottom, 1, -1, Math.Min(bottomLeft, limit), 90); + return points; + } + + private static void Corner(List points, double x, double y, int inX, int inY, double radius, double start) + { + if (radius < 0.5) + { + points.Add(new Point(x, y)); + return; + } + var center = new Point(x + inX * radius, y + inY * radius); + for (var i = 0; i <= 6; i++) points.Add(Polar(center, radius, start + 15 * i)); + } + + // Rounds a range out to steps of 1, 2 or 5 times a power of ten. + public static (double Min, double Max, double Step) NiceScale(double min, double max, int ticks) + { + if (max <= min) max = min + 1; + var rough = (max - min) / Math.Max(1, ticks); + var magnitude = Math.Pow(10, Math.Floor(Math.Log10(rough))); + var fraction = rough / magnitude; + var step = (fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10) * magnitude; + return (Math.Floor(min / step) * step, Math.Ceiling(max / step) * step, step); + } + + // A smooth curve through the points that never overshoots them (monotone cubic). + public static List Smooth(IReadOnlyList points) + { + var count = points.Count; + if (count < 3) return points.ToList(); + var slope = new double[count - 1]; + var tangent = new double[count]; + for (var i = 0; i < count - 1; i++) + { + var dx = points[i + 1].X - points[i].X; + slope[i] = dx == 0 ? 0 : (points[i + 1].Y - points[i].Y) / dx; + } + tangent[0] = slope[0]; + tangent[count - 1] = slope[count - 2]; + for (var i = 1; i < count - 1; i++) tangent[i] = slope[i - 1] * slope[i] <= 0 ? 0 : (slope[i - 1] + slope[i]) / 2; + for (var i = 0; i < count - 1; i++) + { + if (slope[i] == 0) + { + tangent[i] = 0; + tangent[i + 1] = 0; + continue; + } + var a = tangent[i] / slope[i]; + var b = tangent[i + 1] / slope[i]; + var length = a * a + b * b; + if (length <= 9) continue; + var scale = 3 / Math.Sqrt(length); + tangent[i] = scale * a * slope[i]; + tangent[i + 1] = scale * b * slope[i]; + } + + var curve = new List { points[0] }; + for (var i = 0; i < count - 1; i++) + { + var width = points[i + 1].X - points[i].X; + for (var step = 1; step <= 12; step++) + { + var t = step / 12.0; + var t2 = t * t; + var t3 = t2 * t; + var y = (2 * t3 - 3 * t2 + 1) * points[i].Y + (t3 - 2 * t2 + t) * width * tangent[i] + + (-2 * t3 + 3 * t2) * points[i + 1].Y + (t3 - t2) * width * tangent[i + 1]; + curve.Add(new Point(points[i].X + width * t, y)); + } + } + return curve; + } +} + +// Where a bar, line or area chart's plot sits and how values and categories map onto it. +public sealed class ChartFrame +{ + public ChartFrame(Rect plot, double min, double max, int count, bool bands, bool horizontal) + { + Plot = plot; + Min = min; + Max = max; + Count = count; + Bands = bands; + Horizontal = horizontal; + } + + public Rect Plot { get; } + public double Min { get; } + public double Max { get; } + public int Count { get; } + // Bars get a band each; lines and areas run from edge to edge. + public bool Bands { get; } + public bool Horizontal { get; } + + private double Start => Horizontal ? Plot.Top : Plot.Left; + private double Length => Horizontal ? Plot.Height : Plot.Width; + + // The room one category takes along the category axis. + public double Band => Length / Math.Max(1, Bands ? Count : Count - 1); + + // Where a value sits along the value axis. + public double Value(double value) + { + var t = Max > Min ? (value - Min) / (Max - Min) : 0; + return Horizontal ? Plot.Left + t * Plot.Width : Plot.Bottom - t * Plot.Height; + } + + // The middle of a category along the category axis. + public double Category(int index) + { + if (Bands) return Start + (index + 0.5) * Band; + return Count <= 1 ? Start + Length / 2 : Start + index * Band; + } + + // The category under a point, or -1 outside the plot. + public int IndexAt(Point point) + { + if (Count == 0 || point.X < Plot.Left - 12 || point.X > Plot.Right + 12 || point.Y < Plot.Top - 12 || point.Y > Plot.Bottom + 12) return -1; + var along = (Horizontal ? point.Y : point.X) - Start; + var index = Bands ? (int)Math.Floor(along / Band) : (int)Math.Round(along / Band); + return Math.Clamp(index, 0, Count - 1); + } +} + +internal static class ChartColor +{ + public static Color Fade(Color color, double opacity) => new((byte)Math.Round(color.A * opacity), color.R, color.G, color.B); +} + +// IChartCanvas on an Avalonia DrawingContext. +internal sealed class ChartCanvas : IChartCanvas +{ + private readonly DrawingContext _context; + private readonly FontFamily _font; + + public ChartCanvas(DrawingContext context, FontFamily font) + { + _context = context; + _font = font; + } + + public void Line(Point from, Point to, Color color, double width, bool dashed = false) => + _context.DrawLine(new Pen(new ImmutableSolidColorBrush(color), width) { DashStyle = dashed ? new DashStyle(new double[] { 3, 3 }, 0) : null }, from, to); + + public void Polyline(IReadOnlyList points, Color color, double width, bool closed = false) + { + if (points.Count < 2) return; + var pen = new Pen(new ImmutableSolidColorBrush(color), width) { LineJoin = PenLineJoin.Round, LineCap = PenLineCap.Round }; + _context.DrawGeometry(null, pen, Path(points, filled: false, closed)); + } + + public void Polygon(IReadOnlyList points, Color color) + { + if (points.Count < 3) return; + _context.DrawGeometry(new ImmutableSolidColorBrush(color), null, Path(points, filled: true, closed: true)); + } + + public void Text(string text, Point at, double size, Color color, ChartAlign horizontal = ChartAlign.Start, ChartAlign vertical = ChartAlign.Start, bool bold = false) + { + if (string.IsNullOrEmpty(text)) return; + var formatted = Format(text, size, bold, new ImmutableSolidColorBrush(color)); + var x = horizontal == ChartAlign.Start ? at.X : horizontal == ChartAlign.Center ? at.X - formatted.Width / 2 : at.X - formatted.Width; + var y = vertical == ChartAlign.Start ? at.Y : vertical == ChartAlign.Center ? at.Y - formatted.Height / 2 : at.Y - formatted.Height; + _context.DrawText(formatted, new Point(x, y)); + } + + public Size Measure(string text, double size, bool bold = false) + { + if (string.IsNullOrEmpty(text)) return default; + var formatted = Format(text, size, bold, null); + return new Size(Math.Ceiling(formatted.Width), Math.Ceiling(formatted.Height)); + } + + private FormattedText Format(string text, double size, bool bold, IBrush? brush) => + new(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, + new Typeface(_font, FontStyle.Normal, bold ? FontWeight.Medium : FontWeight.Normal), size, brush); + + private static StreamGeometry Path(IReadOnlyList points, bool filled, bool closed) + { + var geometry = new StreamGeometry(); + using var path = geometry.Open(); + path.BeginFigure(points[0], filled); + for (var i = 1; i < points.Count; i++) path.LineTo(points[i]); + path.EndFigure(closed); + return geometry; + } +} + +// What every chart shares: its series and labels, the legend, the tooltip under the pointer and +// the grow-in animation. Give a chart a height. +public abstract class ChartBase : Control +{ + // The category names along the axis, or the slice names of a pie. + public static readonly StyledProperty LabelsProperty = + AvaloniaProperty.Register(nameof(Labels)); + + public static readonly StyledProperty ShowLegendProperty = + AvaloniaProperty.Register(nameof(ShowLegend), true); + + // Shows the values under the pointer (or the finger). + public static readonly StyledProperty ShowTooltipProperty = + AvaloniaProperty.Register(nameof(ShowTooltip), true); + + // Grows the chart in when it first appears. + public static readonly StyledProperty IsAnimatedProperty = + AvaloniaProperty.Register(nameof(IsAnimated), true); + + // A .NET number format for axis and tooltip values. + public static readonly StyledProperty ValueFormatProperty = + AvaloniaProperty.Register(nameof(ValueFormat), "#,0.##"); + + public static readonly StyledProperty EmptyTextProperty = + AvaloniaProperty.Register(nameof(EmptyText), "No data"); + + // What the chart shows, for screen readers. + public static readonly StyledProperty DescriptionProperty = + AvaloniaProperty.Register(nameof(Description), ""); + + private DispatcherTimer? _timer; + private Point _pointer; + private int _hover = -1; + private double _progress = 1; + + static ChartBase() + { + AffectsRender(LabelsProperty, ShowLegendProperty, ShowTooltipProperty, IsAnimatedProperty, ValueFormatProperty, EmptyTextProperty, DescriptionProperty); + DescriptionProperty.Changed.AddClassHandler((chart, e) => AutomationProperties.SetName(chart, (string?)e.NewValue ?? string.Empty)); + } + + public ChartLabels? Labels + { + get => GetValue(LabelsProperty); + set => SetValue(LabelsProperty, value); + } + + public bool ShowLegend + { + get => GetValue(ShowLegendProperty); + set => SetValue(ShowLegendProperty, value); + } + + public bool ShowTooltip + { + get => GetValue(ShowTooltipProperty); + set => SetValue(ShowTooltipProperty, value); + } + + public bool IsAnimated + { + get => GetValue(IsAnimatedProperty); + set => SetValue(IsAnimatedProperty, value); + } + + public string? ValueFormat + { + get => GetValue(ValueFormatProperty); + set => SetValue(ValueFormatProperty, value); + } + + public string? EmptyText + { + get => GetValue(EmptyTextProperty); + set => SetValue(EmptyTextProperty, value); + } + + public string? Description + { + get => GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + [Content] + public AvaloniaList Series { get; } = new(); + + protected ChartBase() + { + MinHeight = 160; + Series.CollectionChanged += (_, _) => + { + foreach (var series in Series) series.Changed = Refresh; + Refresh(); + }; + } + + // Redraws the chart. Call it after changing the numbers inside a series' Values. + public void Refresh() => InvalidateVisual(); + + // Grows the chart in again. + public void PlayAnimation() + { + _timer?.Stop(); + _timer = null; + if (!IsAnimated) + { + _progress = 1; + Refresh(); + return; + } + var started = DateTime.UtcNow; + _progress = 0; + _timer = new DispatcherTimer(TimeSpan.FromMilliseconds(16), DispatcherPriority.Render, (_, _) => + { + var t = Math.Min(1, (DateTime.UtcNow - started).TotalMilliseconds / 600); + // Ease out, like MAUI's CubicOut. + _progress = 1 - Math.Pow(1 - t, 3); + Refresh(); + if (t < 1) return; + _timer?.Stop(); + _timer = null; + }); + _timer.Start(); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + // A control only takes the pointer where it has drawn something. + context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size)); + Paint(new ChartCanvas(context, TextElement.GetFontFamily(this)), Bounds.Size); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + ShellTheme.ThemeChanged += OnThemeChanged; + PlayAnimation(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + ShellTheme.ThemeChanged -= OnThemeChanged; + _timer?.Stop(); + _timer = null; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + Hover(e.GetPosition(this)); + } + + // A touch has no hover: a press shows the tooltip instead. + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + Hover(e.GetPosition(this)); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + Hover(null); + } + + // The theme's chart colors in order; past the fifth they repeat, fainter. + protected static Color Palette(int index) + { + var color = ShellTheme.Get(ShellToken.Chart1 + index % 5); + return index < 5 ? color : ChartColor.Fade(color, 0.6); + } + + protected Color SeriesColor(int index) => Series[index].Color ?? Palette(index); + + protected int CategoryCount => Math.Max(Labels?.Count ?? 0, Series.Count == 0 ? 0 : Series.Max(s => s.Values.Count)); + + protected string LabelAt(int index) => + Labels != null && index < Labels.Count ? Labels[index] : (index + 1).ToString(CultureInfo.CurrentCulture); + + protected static double ValueAt(ChartSeries series, int index) => index < series.Values.Count ? series.Values[index] : 0; + + protected string Format(double value) => + value.ToString(string.IsNullOrEmpty(ValueFormat) ? "#,0.##" : ValueFormat, CultureInfo.CurrentCulture); + + // 0 to 1 while the chart animates in, then 1. + protected double Progress => IsAnimated ? _progress : 1; + + // The category, slice or ring under the pointer, or -1. + protected int HoveredIndex => ShowTooltip ? _hover : -1; + + protected virtual bool HasData => Series.Any(s => s.Values.Count > 0); + + // Draws the chart itself into `area`; the legend and tooltip are added around it. + protected abstract void DrawChart(IChartCanvas canvas, Rect area); + + protected virtual int HitTest(Point point) => -1; + + protected virtual List<(Color Color, string Name)> LegendEntries() => + Series.Select((s, i) => (SeriesColor(i), s.Name)).Where(entry => !string.IsNullOrEmpty(entry.Name)).ToList(); + + protected virtual (string Title, List<(Color Color, string Name, string Value)> Rows) Tooltip(int index) => + (LabelAt(index), Series.Select((s, i) => (SeriesColor(i), s.Name, Format(ValueAt(s, index)))).ToList()); + + private void OnThemeChanged(object? sender, EventArgs e) => Refresh(); + + private void Hover(Point? point) + { + var index = point is { } at ? HitTest(at) : -1; + if (index < 0 && _hover < 0) return; + _pointer = point ?? _pointer; + _hover = index; + Refresh(); + } + + private void Paint(IChartCanvas canvas, Size size) + { + if (size.Width < 8 || size.Height < 8) return; + if (!HasData) + { + canvas.Text(EmptyText ?? string.Empty, new Point(size.Width / 2, size.Height / 2), 14, + ShellTheme.Get(ShellToken.MutedForeground), ChartAlign.Center, ChartAlign.Center); + return; + } + + var legend = ShowLegend ? LegendEntries() : new List<(Color Color, string Name)>(); + var legendHeight = legend.Count > 0 ? DrawLegend(canvas, legend, size, draw: false) : 0; + DrawChart(canvas, new Rect(0, 0, size.Width, size.Height - legendHeight)); + if (legend.Count > 0) DrawLegend(canvas, legend, size, draw: true); + if (HoveredIndex >= 0) DrawTooltip(canvas, size); + } + + // justify-center gap-4 text-xs: a 8px rounded square and the name per entry, wrapped into + // as many centered rows as it takes. Returns the height the legend needs. + private static double DrawLegend(IChartCanvas canvas, List<(Color Color, string Name)> entries, Size size, bool draw) + { + const double fontSize = 12, mark = 8, markGap = 6, gap = 16, rowHeight = 20, top = 12; + var widths = entries.Select(e => mark + markGap + canvas.Measure(e.Name, fontSize).Width).ToList(); + var rows = new List> { new() }; + var used = 0.0; + for (var i = 0; i < entries.Count; i++) + { + var next = used == 0 ? widths[i] : used + gap + widths[i]; + if (used > 0 && next > size.Width) + { + rows.Add(new List()); + next = widths[i]; + } + rows[^1].Add(i); + used = next; + } + + var height = top + rows.Count * rowHeight; + if (!draw) return height; + var text = ShellTheme.Get(ShellToken.Foreground); + var y = size.Height - rows.Count * rowHeight; + foreach (var row in rows) + { + var x = (size.Width - row.Sum(i => widths[i]) - gap * (row.Count - 1)) / 2; + foreach (var i in row) + { + var middle = y + rowHeight / 2; + canvas.Polygon(ChartMath.RoundRect(new Rect(x, middle - mark / 2, mark, mark), 2, 2, 2, 2), entries[i].Color); + canvas.Text(entries[i].Name, new Point(x + mark + markGap, middle), fontSize, text, ChartAlign.Start, ChartAlign.Center); + x += widths[i] + gap; + } + y += rowHeight; + } + return height; + } + + // rounded-lg border bg-background px-2.5 py-1.5 text-xs: a title, then a colored square, + // the name and the value per row. It follows the pointer and stays inside the chart. + private void DrawTooltip(IChartCanvas canvas, Size size) + { + var (title, rows) = Tooltip(_hover); + if (rows.Count == 0) return; + const double fontSize = 12, pad = 10, rowHeight = 18, mark = 10; + var hasTitle = !string.IsNullOrEmpty(title); + var names = rows.Max(r => canvas.Measure(r.Name, fontSize).Width); + var values = rows.Max(r => canvas.Measure(r.Value, fontSize, bold: true).Width); + var width = Math.Max(128, pad * 2 + mark + 6 + names + 16 + values); + if (hasTitle) width = Math.Max(width, pad * 2 + canvas.Measure(title, fontSize, bold: true).Width); + var height = 16 + rows.Count * rowHeight + (hasTitle ? rowHeight + 2 : 0); + + var x = _pointer.X + 14; + if (x + width > size.Width) x = _pointer.X - 14 - width; + x = Math.Clamp(x, 0, Math.Max(0, size.Width - width)); + var y = _pointer.Y + 14; + if (y + height > size.Height) y = _pointer.Y - 14 - height; + y = Math.Clamp(y, 0, Math.Max(0, size.Height - height)); + + canvas.Polygon(ChartMath.RoundRect(new Rect(x, y, width, height), 8, 8, 8, 8), ShellTheme.Get(ShellToken.Border)); + canvas.Polygon(ChartMath.RoundRect(new Rect(x + 1, y + 1, width - 2, height - 2), 7, 7, 7, 7), ShellTheme.Get(ShellToken.Background)); + var foreground = ShellTheme.Get(ShellToken.Foreground); + var muted = ShellTheme.Get(ShellToken.MutedForeground); + var top = y + 8; + if (hasTitle) + { + canvas.Text(title, new Point(x + pad, top + rowHeight / 2), fontSize, foreground, ChartAlign.Start, ChartAlign.Center, bold: true); + top += rowHeight + 2; + } + foreach (var row in rows) + { + var middle = top + rowHeight / 2; + canvas.Polygon(ChartMath.RoundRect(new Rect(x + pad, middle - mark / 2, mark, mark), 2, 2, 2, 2), row.Color); + canvas.Text(row.Name, new Point(x + pad + mark + 6, middle), fontSize, muted, ChartAlign.Start, ChartAlign.Center); + canvas.Text(row.Value, new Point(x + width - pad, middle), fontSize, foreground, ChartAlign.End, ChartAlign.Center, bold: true); + top += rowHeight; + } + } + + // A large value over a small label, for the middle of a donut or radial chart. + protected static void DrawCenterText(IChartCanvas canvas, Point center, string? value, string? label) + { + var hasValue = !string.IsNullOrEmpty(value); + var hasLabel = !string.IsNullOrEmpty(label); + if (hasValue) + canvas.Text(value!, new Point(center.X, center.Y - (hasLabel ? 8 : 0)), 24, ShellTheme.Get(ShellToken.Foreground), ChartAlign.Center, ChartAlign.Center, bold: true); + if (hasLabel) + canvas.Text(label!, new Point(center.X, center.Y + (hasValue ? 14 : 0)), 12, ShellTheme.Get(ShellToken.MutedForeground), ChartAlign.Center, ChartAlign.Center); + } +} + +// What bar, line and area charts share: a value axis, a category axis and a grid. +public abstract class CartesianChart : ChartBase +{ + public static readonly StyledProperty ShowGridProperty = + AvaloniaProperty.Register(nameof(ShowGrid), true); + + // The labels under the plot. + public static readonly StyledProperty ShowXAxisProperty = + AvaloniaProperty.Register(nameof(ShowXAxis), true); + + // The labels beside the plot. + public static readonly StyledProperty ShowYAxisProperty = + AvaloniaProperty.Register(nameof(ShowYAxis), true); + + // The ends of the value axis; by default they fit the data and include zero. + public static readonly StyledProperty MinimumProperty = + AvaloniaProperty.Register(nameof(Minimum), double.NaN); + + public static readonly StyledProperty MaximumProperty = + AvaloniaProperty.Register(nameof(Maximum), double.NaN); + + static CartesianChart() + { + AffectsRender(ShowGridProperty, ShowXAxisProperty, ShowYAxisProperty, MinimumProperty, MaximumProperty); + } + + public bool ShowGrid + { + get => GetValue(ShowGridProperty); + set => SetValue(ShowGridProperty, value); + } + + public bool ShowXAxis + { + get => GetValue(ShowXAxisProperty); + set => SetValue(ShowXAxisProperty, value); + } + + public bool ShowYAxis + { + get => GetValue(ShowYAxisProperty); + set => SetValue(ShowYAxisProperty, value); + } + + public double Minimum + { + get => GetValue(MinimumProperty); + set => SetValue(MinimumProperty, value); + } + + public double Maximum + { + get => GetValue(MaximumProperty); + set => SetValue(MaximumProperty, value); + } + + // The frame of the last draw, for hit testing. + protected ChartFrame? LastFrame { get; private set; } + + protected override int HitTest(Point point) => LastFrame?.IndexAt(point) ?? -1; + + // Draws the grid and the axis labels into `area` and returns how values and categories map + // onto the plot. The value axis always includes zero unless Minimum or Maximum say otherwise. + protected ChartFrame DrawAxes(IChartCanvas canvas, Rect area, double dataMin, double dataMax, bool bands, bool horizontal = false) + { + const double fontSize = 12; + var muted = ShellTheme.Get(ShellToken.MutedForeground); + var line = ShellTheme.Get(ShellToken.Border); + var count = CategoryCount; + var (low, high, step) = ChartMath.NiceScale( + double.IsNaN(Minimum) ? Math.Min(0, dataMin) : Minimum, + double.IsNaN(Maximum) ? Math.Max(0, dataMax) : Maximum, 5); + if (!double.IsNaN(Minimum)) low = Minimum; + if (!double.IsNaN(Maximum)) high = Maximum; + var ticks = new List(); + for (var tick = Math.Ceiling(low / step) * step; tick <= high + step / 1000; tick += step) + ticks.Add(Math.Abs(tick) < step / 1000 ? 0 : tick); + + var showValues = horizontal ? ShowXAxis : ShowYAxis; + var showCategories = horizontal ? ShowYAxis : ShowXAxis; + var labels = Enumerable.Range(0, count).Select(LabelAt).ToList(); + double left = 0; + if (horizontal && showCategories) left = labels.Max(label => canvas.Measure(label, fontSize).Width) + 8; + if (!horizontal && showValues) left = ticks.Max(tick => canvas.Measure(Format(tick), fontSize).Width) + 8; + // An edge-to-edge axis needs room for half of its first and last label. + if (!bands && !horizontal) left = Math.Max(left, 12); + var right = horizontal ? 16 : bands ? 0 : 12; + var bottom = (horizontal ? showValues : showCategories) ? 24 : 0; + var plot = new Rect(area.Left + left, area.Top + 8, Math.Max(1, area.Width - left - right), Math.Max(1, area.Height - 8 - bottom)); + var frame = new ChartFrame(plot, low, high, count, bands, horizontal); + + foreach (var tick in ticks) + { + var at = frame.Value(tick); + // Half a pixel off the grid keeps a 1px line sharp. + var sharp = Math.Round(at) + 0.5; + if (ShowGrid) + { + if (horizontal) canvas.Line(new Point(sharp, plot.Top), new Point(sharp, plot.Bottom), line, 1); + else canvas.Line(new Point(plot.Left, sharp), new Point(plot.Right, sharp), line, 1); + } + if (!showValues) continue; + if (horizontal) canvas.Text(Format(tick), new Point(at, plot.Bottom + 8), fontSize, muted, ChartAlign.Center); + else canvas.Text(Format(tick), new Point(plot.Left - 8, at), fontSize, muted, ChartAlign.End, ChartAlign.Center); + } + + if (showCategories && horizontal) + { + for (var i = 0; i < count; i++) + canvas.Text(labels[i], new Point(plot.Left - 8, frame.Category(i)), fontSize, muted, ChartAlign.End, ChartAlign.Center); + } + else if (showCategories) + { + // Labels that would run into each other are thinned out. + var widest = labels.Max(label => canvas.Measure(label, fontSize).Width); + var every = Math.Max(1, (int)Math.Ceiling((widest + 8) / Math.Max(1, frame.Band))); + for (var i = 0; i < count; i += every) + canvas.Text(labels[i], new Point(frame.Category(i), plot.Bottom + 8), fontSize, muted, ChartAlign.Center); + } + + LastFrame = frame; + return frame; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/LineChart.cs b/examples/Avalonia.Demo/Components/UI/LineChart.cs new file mode 100644 index 0000000..598ec33 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/LineChart.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Line chart — one line per series from edge to edge of the plot, smooth by default. The +// hovered category gets a hairline, a dot on each line and a tooltip. +// +// +// +// +public class LineChart : CartesianChart +{ + // Curves the line through its points; false joins them with straight segments. + public static readonly StyledProperty IsSmoothProperty = + AvaloniaProperty.Register(nameof(IsSmooth), true); + + // A dot on every point. + public static readonly StyledProperty ShowDotsProperty = + AvaloniaProperty.Register(nameof(ShowDots)); + + public static readonly StyledProperty StrokeWidthProperty = + AvaloniaProperty.Register(nameof(StrokeWidth), 2d); + + static LineChart() + { + AffectsRender(IsSmoothProperty, ShowDotsProperty, StrokeWidthProperty); + } + + public bool IsSmooth + { + get => GetValue(IsSmoothProperty); + set => SetValue(IsSmoothProperty, value); + } + + public bool ShowDots + { + get => GetValue(ShowDotsProperty); + set => SetValue(ShowDotsProperty, value); + } + + public double StrokeWidth + { + get => GetValue(StrokeWidthProperty); + set => SetValue(StrokeWidthProperty, value); + } + + protected override void DrawChart(IChartCanvas canvas, Rect area) + { + var all = Series.SelectMany(s => s.Values).DefaultIfEmpty(0).ToList(); + var frame = DrawAxes(canvas, area, all.Min(), all.Max(), bands: false); + var plot = frame.Plot; + var hovered = HoveredIndex; + if (hovered >= 0) + { + // The cursor: a hairline through the hovered category. + var x = Math.Round(frame.Category(hovered)) + 0.5; + canvas.Line(new Point(x, plot.Top), new Point(x, plot.Bottom), ShellTheme.Get(ShellToken.Border), 1); + } + + for (var s = 0; s < Series.Count; s++) + { + var values = Series[s].Values; + if (values.Count == 0) continue; + var color = SeriesColor(s); + var points = values.Select((value, i) => new Point(frame.Category(i), frame.Value(value * Progress))).ToList(); + if (points.Count > 1) canvas.Polyline(IsSmooth ? ChartMath.Smooth(points) : points, color, StrokeWidth); + if (ShowDots || points.Count == 1) + foreach (var point in points) canvas.Polygon(ChartMath.Circle(point, StrokeWidth + 1.5), color); + if (hovered < 0 || hovered >= points.Count) continue; + canvas.Polygon(ChartMath.Circle(points[hovered], StrokeWidth + 4), ShellTheme.Get(ShellToken.Background)); + canvas.Polygon(ChartMath.Circle(points[hovered], StrokeWidth + 2.5), color); + } + } +} diff --git a/examples/Avalonia.Demo/Components/UI/PieChart.cs b/examples/Avalonia.Demo/Components/UI/PieChart.cs new file mode 100644 index 0000000..1f0ef36 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/PieChart.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Pie chart — the first series' values as slices, clockwise from twelve o'clock, named by +// Labels and colored from the theme's chart colors in order. The hovered slice stands out and +// shows its value and share. DonutChart is the same with a hole for a value and a label. +// +// +// +// +// +// +public class PieChart : ChartBase +{ + // The hole, as a share of the radius: 0 is a pie, 0.6 a donut. + public static readonly StyledProperty InnerRadiusProperty = + AvaloniaProperty.Register(nameof(InnerRadius)); + + // Degrees of space between slices. + public static readonly StyledProperty GapProperty = + AvaloniaProperty.Register(nameof(Gap), 1d); + + // Writes each slice's value outside it. + public static readonly StyledProperty ShowLabelsProperty = + AvaloniaProperty.Register(nameof(ShowLabels)); + + // Large text in the hole of a donut. + public static readonly StyledProperty CenterValueProperty = + AvaloniaProperty.Register(nameof(CenterValue), ""); + + // Small muted text under CenterValue. + public static readonly StyledProperty CenterLabelProperty = + AvaloniaProperty.Register(nameof(CenterLabel), ""); + + static PieChart() + { + AffectsRender(InnerRadiusProperty, GapProperty, ShowLabelsProperty, CenterValueProperty, CenterLabelProperty); + } + + public double InnerRadius + { + get => GetValue(InnerRadiusProperty); + set => SetValue(InnerRadiusProperty, value); + } + + public double Gap + { + get => GetValue(GapProperty); + set => SetValue(GapProperty, value); + } + + public bool ShowLabels + { + get => GetValue(ShowLabelsProperty); + set => SetValue(ShowLabelsProperty, value); + } + + public string? CenterValue + { + get => GetValue(CenterValueProperty); + set => SetValue(CenterValueProperty, value); + } + + public string? CenterLabel + { + get => GetValue(CenterLabelProperty); + set => SetValue(CenterLabelProperty, value); + } + + // The slices of the last draw, for hit testing. + private readonly List<(double Start, double Sweep)> _slices = new(); + private Point _center; + private double _inner; + private double _outer; + + private List Slices() => + Series.Count == 0 ? new List() : Series[0].Values.Select(value => Math.Max(0, value)).ToList(); + + protected override bool HasData => Slices().Any(value => value > 0); + + protected override void DrawChart(IChartCanvas canvas, Rect area) + { + var values = Slices(); + var total = values.Sum(); + _slices.Clear(); + _center = new Point(area.Left + area.Width / 2, area.Top + area.Height / 2); + _outer = Math.Max(4, Math.Min(area.Width, area.Height) / 2 - (ShowLabels ? 30 : 10)); + _inner = _outer * Math.Clamp(InnerRadius, 0, 0.95); + var gap = values.Count(value => value > 0) > 1 ? Gap : 0; + var start = -90.0; + for (var i = 0; i < values.Count; i++) + { + var sweep = values[i] / total * 360 * Progress; + _slices.Add((start, sweep)); + if (sweep > gap) + { + // The hovered slice stands out a little. + var grow = HoveredIndex == i ? 6 : 0; + canvas.Polygon(ChartMath.Sector(_center, _inner, _outer + grow, start + gap / 2, sweep - gap), Palette(i)); + if (ShowLabels && Progress >= 1) + canvas.Text(Format(values[i]), ChartMath.Polar(_center, _outer + 16, start + sweep / 2), 12, + ShellTheme.Get(ShellToken.Foreground), ChartAlign.Center, ChartAlign.Center); + } + start += sweep; + } + if (_inner > 0) DrawCenterText(canvas, _center, CenterValue, CenterLabel); + } + + protected override int HitTest(Point point) + { + var dx = point.X - _center.X; + var dy = point.Y - _center.Y; + var distance = Math.Sqrt(dx * dx + dy * dy); + if (distance < _inner || distance > _outer + 6) return -1; + // Clockwise from twelve o'clock, like the slices. + var angle = (Math.Atan2(dy, dx) * 180 / Math.PI + 90 + 360) % 360; + for (var i = 0; i < _slices.Count; i++) + { + var from = _slices[i].Start + 90; + if (angle >= from && angle < from + _slices[i].Sweep) return i; + } + return -1; + } + + protected override List<(Color Color, string Name)> LegendEntries() => + Slices().Select((_, i) => (Palette(i), LabelAt(i))).ToList(); + + protected override (string Title, List<(Color Color, string Name, string Value)> Rows) Tooltip(int index) + { + var values = Slices(); + var share = values[index] / values.Sum(); + return (string.Empty, new List<(Color Color, string Name, string Value)> + { + (Palette(index), LabelAt(index), $"{Format(values[index])} ({share.ToString("P0", CultureInfo.CurrentCulture)})") + }); + } +} + +// A pie with a hole — InnerRadius starts at 0.6, with CenterValue and CenterLabel in the middle. +public class DonutChart : PieChart +{ + public DonutChart() + { + InnerRadius = 0.6; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/RadarChart.cs b/examples/Avalonia.Demo/Components/UI/RadarChart.cs new file mode 100644 index 0000000..ad18b24 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/RadarChart.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Radar chart — one spoke per category around a ringed grid, and a filled shape per series. +// It needs at least three categories. The hovered spoke shows every series' value there. +// +// +// +// +public class RadarChart : ChartBase +{ + // The value at the outer ring; by default it fits the data. + public static readonly StyledProperty MaximumProperty = + AvaloniaProperty.Register(nameof(Maximum), double.NaN); + + public static readonly StyledProperty FillOpacityProperty = + AvaloniaProperty.Register(nameof(FillOpacity), 0.5); + + // A dot on every point. + public static readonly StyledProperty ShowDotsProperty = + AvaloniaProperty.Register(nameof(ShowDots)); + + static RadarChart() + { + AffectsRender(MaximumProperty, FillOpacityProperty, ShowDotsProperty); + } + + public double Maximum + { + get => GetValue(MaximumProperty); + set => SetValue(MaximumProperty, value); + } + + public double FillOpacity + { + get => GetValue(FillOpacityProperty); + set => SetValue(FillOpacityProperty, value); + } + + public bool ShowDots + { + get => GetValue(ShowDotsProperty); + set => SetValue(ShowDotsProperty, value); + } + + private Point _center; + private double _radius; + + // A radar needs at least three axes. + protected override bool HasData => base.HasData && CategoryCount >= 3; + + protected override void DrawChart(IChartCanvas canvas, Rect area) + { + var count = CategoryCount; + var line = ShellTheme.Get(ShellToken.Border); + var muted = ShellTheme.Get(ShellToken.MutedForeground); + _center = new Point(area.Left + area.Width / 2, area.Top + area.Height / 2); + _radius = Math.Max(4, Math.Min(area.Width, area.Height) / 2 - 32); + var max = double.IsNaN(Maximum) + ? ChartMath.NiceScale(0, Series.SelectMany(s => s.Values).DefaultIfEmpty(1).Max(), 4).Max + : Maximum; + if (max <= 0) max = 1; + double Angle(int i) => -90 + 360.0 * i / count; + + // Four rings and a spoke per axis. + for (var level = 1; level <= 4; level++) + canvas.Polyline(Enumerable.Range(0, count).Select(i => ChartMath.Polar(_center, _radius * level / 4, Angle(i))).ToList(), line, 1, closed: true); + for (var i = 0; i < count; i++) + { + canvas.Line(_center, ChartMath.Polar(_center, _radius, Angle(i)), line, 1); + var cos = Math.Cos(Angle(i) * Math.PI / 180); + var sin = Math.Sin(Angle(i) * Math.PI / 180); + // Each label hangs off its spoke, away from the middle. + canvas.Text(LabelAt(i), ChartMath.Polar(_center, _radius + 10, Angle(i)), 12, muted, + cos > 0.3 ? ChartAlign.Start : cos < -0.3 ? ChartAlign.End : ChartAlign.Center, + sin > 0.3 ? ChartAlign.Start : sin < -0.3 ? ChartAlign.End : ChartAlign.Center); + } + + var hovered = HoveredIndex; + for (var s = 0; s < Series.Count; s++) + { + var color = SeriesColor(s); + var points = Enumerable.Range(0, count) + .Select(i => ChartMath.Polar(_center, _radius * Math.Clamp(ValueAt(Series[s], i) / max, 0, 1) * Progress, Angle(i))) + .ToList(); + canvas.Polygon(points, ChartColor.Fade(color, FillOpacity)); + canvas.Polyline(points, color, 2, closed: true); + if (ShowDots) + foreach (var point in points) canvas.Polygon(ChartMath.Circle(point, 3.5), color); + if (hovered < 0) continue; + canvas.Polygon(ChartMath.Circle(points[hovered], 6), ShellTheme.Get(ShellToken.Background)); + canvas.Polygon(ChartMath.Circle(points[hovered], 4.5), color); + } + } + + protected override int HitTest(Point point) + { + var count = CategoryCount; + var dx = point.X - _center.X; + var dy = point.Y - _center.Y; + if (count == 0 || Math.Sqrt(dx * dx + dy * dy) > _radius + 24) return -1; + // The nearest spoke, clockwise from twelve o'clock. + var angle = (Math.Atan2(dy, dx) * 180 / Math.PI + 90 + 360) % 360; + return (int)Math.Round(angle / (360.0 / count)) % count; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/RadialChart.cs b/examples/Avalonia.Demo/Components/UI/RadialChart.cs new file mode 100644 index 0000000..34b75f5 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/RadialChart.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Radial chart — the first series' values as concentric rings on a muted track, each filling +// clockwise from twelve o'clock towards Maximum. Rings are named by Labels and colored from +// the theme's chart colors in order, the first outermost. +// +// +// +public class RadialChart : ChartBase +{ + // The value of a full ring; by default the largest value. + public static readonly StyledProperty MaximumProperty = + AvaloniaProperty.Register(nameof(Maximum), double.NaN); + + // The hole in the middle, as a share of the radius. + public static readonly StyledProperty InnerRadiusProperty = + AvaloniaProperty.Register(nameof(InnerRadius), 0.35); + + // Rounds the ends of each ring. + public static readonly StyledProperty IsRoundedProperty = + AvaloniaProperty.Register(nameof(IsRounded), true); + + // Large text in the middle. + public static readonly StyledProperty CenterValueProperty = + AvaloniaProperty.Register(nameof(CenterValue), ""); + + // Small muted text under CenterValue. + public static readonly StyledProperty CenterLabelProperty = + AvaloniaProperty.Register(nameof(CenterLabel), ""); + + static RadialChart() + { + AffectsRender(MaximumProperty, InnerRadiusProperty, IsRoundedProperty, CenterValueProperty, CenterLabelProperty); + } + + public double Maximum + { + get => GetValue(MaximumProperty); + set => SetValue(MaximumProperty, value); + } + + public double InnerRadius + { + get => GetValue(InnerRadiusProperty); + set => SetValue(InnerRadiusProperty, value); + } + + public bool IsRounded + { + get => GetValue(IsRoundedProperty); + set => SetValue(IsRoundedProperty, value); + } + + public string? CenterValue + { + get => GetValue(CenterValueProperty); + set => SetValue(CenterValueProperty, value); + } + + public string? CenterLabel + { + get => GetValue(CenterLabelProperty); + set => SetValue(CenterLabelProperty, value); + } + + private Point _center; + private double _outer; + private double _hole; + private double _band; + + protected override bool HasData => Series.Count > 0 && Series[0].Values.Count > 0; + + protected override void DrawChart(IChartCanvas canvas, Rect area) + { + var values = Series[0].Values; + _center = new Point(area.Left + area.Width / 2, area.Top + area.Height / 2); + _outer = Math.Max(8, Math.Min(area.Width, area.Height) / 2 - 8); + _hole = _outer * Math.Clamp(InnerRadius, 0.05, 0.9); + _band = (_outer - _hole) / values.Count; + var thickness = _band - Math.Min(4, _band * 0.25); + var max = double.IsNaN(Maximum) ? values.Max() : Maximum; + if (max <= 0) max = 1; + var track = ShellTheme.Get(ShellToken.Muted); + + // One ring per value, the first outermost, each filling clockwise from twelve o'clock. + for (var i = 0; i < values.Count; i++) + { + var outer = _outer - i * _band; + var inner = outer - thickness; + canvas.Polygon(ChartMath.Sector(_center, inner, outer, 0, 360), track); + var sweep = Math.Clamp(values[i] / max, 0, 1) * 360 * Progress; + if (sweep <= 0) continue; + var grow = HoveredIndex == i ? 2 : 0; + var color = Palette(i); + canvas.Polygon(ChartMath.Sector(_center, inner - grow, outer + grow, -90, sweep), color); + if (!IsRounded || sweep >= 359) continue; + var middle = (inner + outer) / 2; + canvas.Polygon(ChartMath.Circle(ChartMath.Polar(_center, middle, -90), thickness / 2 + grow), color); + canvas.Polygon(ChartMath.Circle(ChartMath.Polar(_center, middle, -90 + sweep), thickness / 2 + grow), color); + } + DrawCenterText(canvas, _center, CenterValue, CenterLabel); + } + + protected override int HitTest(Point point) + { + var dx = point.X - _center.X; + var dy = point.Y - _center.Y; + var distance = Math.Sqrt(dx * dx + dy * dy); + if (_band <= 0 || distance < _hole || distance > _outer) return -1; + return Math.Clamp((int)Math.Floor((_outer - distance) / _band), 0, Series[0].Values.Count - 1); + } + + protected override List<(Color Color, string Name)> LegendEntries() => + Series[0].Values.Select((_, i) => (Palette(i), LabelAt(i))).ToList(); + + protected override (string Title, List<(Color Color, string Name, string Value)> Rows) Tooltip(int index) => + (string.Empty, new List<(Color Color, string Name, string Value)> { (Palette(index), LabelAt(index), Format(Series[0].Values[index])) }); +} diff --git a/examples/Avalonia.Demo/Components/UI/Shell.cs b/examples/Avalonia.Demo/Components/UI/Shell.cs index dd325d6..41d6a8a 100644 --- a/examples/Avalonia.Demo/Components/UI/Shell.cs +++ b/examples/Avalonia.Demo/Components/UI/Shell.cs @@ -45,7 +45,8 @@ public enum ShellToken Warning, WarningForeground, Info, InfoForeground, Border, Input, Ring, - Overlay + Overlay, + Chart1, Chart2, Chart3, Chart4, Chart5 } /* Publishes both palettes as theme dictionaries of the application resources ("ShellUIPrimary" is @@ -87,6 +88,11 @@ public static class ShellTheme [ShellToken.Input] = Color.Parse("#E4E4E4"), [ShellToken.Ring] = Color.Parse("#A3A3A3"), [ShellToken.Overlay] = Color.Parse("#80000000"), + [ShellToken.Chart1] = Color.Parse("#FFAE04"), + [ShellToken.Chart2] = Color.Parse("#2D62EF"), + [ShellToken.Chart3] = Color.Parse("#A4A4A4"), + [ShellToken.Chart4] = Color.Parse("#E4E4E4"), + [ShellToken.Chart5] = Color.Parse("#747474"), }; public static Dictionary Dark { get; } = new() @@ -117,6 +123,11 @@ public static class ShellTheme [ShellToken.Input] = Color.Parse("#333333"), [ShellToken.Ring] = Color.Parse("#737373"), [ShellToken.Overlay] = Color.Parse("#B3000000"), + [ShellToken.Chart1] = Color.Parse("#FFAE04"), + [ShellToken.Chart2] = Color.Parse("#2671F4"), + [ShellToken.Chart3] = Color.Parse("#747474"), + [ShellToken.Chart4] = Color.Parse("#525252"), + [ShellToken.Chart5] = Color.Parse("#E4E4E4"), }; // --radius: 0.5rem. Tailwind's rounded-sm / rounded-md / rounded-lg derive from it. diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml index 898b46c..2490931 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml +++ b/examples/Avalonia.Demo/MainWindow.axaml @@ -728,6 +728,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/MAUI.Demo/Components/UI/AreaChart.cs b/examples/MAUI.Demo/Components/UI/AreaChart.cs new file mode 100644 index 0000000..4e7386b --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/AreaChart.cs @@ -0,0 +1,108 @@ +using System.Globalization; + +namespace MAUI.Demo.Components.UI; + +// Area chart — a line per series with the space under it filled at 40%. Stacked, each +// series sits on the ones before it. +// +// +// +// +public partial class AreaChart : CartesianChart +{ + // Curves the line through its points; false joins them with straight segments. + public static readonly BindableProperty IsSmoothProperty = + BindableProperty.Create(nameof(IsSmooth), typeof(bool), typeof(AreaChart), true, + propertyChanged: (b, o, n) => ((AreaChart)b).Refresh()); + + // Stacks the series instead of overlapping them. + public static readonly BindableProperty IsStackedProperty = + BindableProperty.Create(nameof(IsStacked), typeof(bool), typeof(AreaChart), false, + propertyChanged: (b, o, n) => ((AreaChart)b).Refresh()); + + public static readonly BindableProperty FillOpacityProperty = + BindableProperty.Create(nameof(FillOpacity), typeof(double), typeof(AreaChart), 0.4, + propertyChanged: (b, o, n) => ((AreaChart)b).Refresh()); + + public static readonly BindableProperty StrokeWidthProperty = + BindableProperty.Create(nameof(StrokeWidth), typeof(double), typeof(AreaChart), 2d, + propertyChanged: (b, o, n) => ((AreaChart)b).Refresh()); + + public bool IsSmooth + { + get => (bool)GetValue(IsSmoothProperty); + set => SetValue(IsSmoothProperty, value); + } + + public bool IsStacked + { + get => (bool)GetValue(IsStackedProperty); + set => SetValue(IsStackedProperty, value); + } + + public double FillOpacity + { + get => (double)GetValue(FillOpacityProperty); + set => SetValue(FillOpacityProperty, value); + } + + public double StrokeWidth + { + get => (double)GetValue(StrokeWidthProperty); + set => SetValue(StrokeWidthProperty, value); + } + + protected override void DrawChart(IChartCanvas canvas, Rect area) + { + var count = CategoryCount; + double min = 0, max = 0; + for (var i = 0; i < count; i++) + { + var values = Series.Select(s => ValueAt(s, i)).ToList(); + max = Math.Max(max, IsStacked ? values.Where(v => v > 0).Sum() : values.Max()); + min = Math.Min(min, IsStacked ? values.Where(v => v < 0).Sum() : values.Min()); + } + + var frame = DrawAxes(canvas, area, min, max, bands: false); + var plot = frame.Plot; + var hovered = HoveredIndex; + if (hovered >= 0) + { + // The cursor: a hairline through the hovered category. + var x = Math.Round(frame.Category(hovered)) + 0.5; + canvas.Line(new Point(x, plot.Top), new Point(x, plot.Bottom), ShellTheme.Get(ShellToken.Border), 1); + } + + // In a stack each series sits on the running total of the ones before it. + var stack = new double[count]; + var dots = new List<(Point At, Color Color)>(); + for (var s = 0; s < Series.Count; s++) + { + var color = SeriesColor(s); + var top = new List(); + var bottom = new List(); + for (var i = 0; i < count; i++) + { + var from = IsStacked ? stack[i] : 0; + var to = from + ValueAt(Series[s], i) * Progress; + if (IsStacked) stack[i] = to; + top.Add(new Point(frame.Category(i), frame.Value(to))); + bottom.Add(new Point(frame.Category(i), frame.Value(from))); + } + + var upper = IsSmooth ? ChartMath.Smooth(top) : top; + var lower = IsSmooth ? ChartMath.Smooth(bottom) : bottom; + var shape = new List(upper); + shape.AddRange(Enumerable.Reverse(lower)); + canvas.Polygon(shape, ChartColor.Fade(color, FillOpacity)); + if (upper.Count > 1) canvas.Polyline(upper, color, StrokeWidth); + if (hovered >= 0 && hovered < top.Count) dots.Add((top[hovered], color)); + } + + foreach (var dot in dots) + { + canvas.Polygon(ChartMath.Circle(dot.At, StrokeWidth + 4), ShellTheme.Get(ShellToken.Background)); + canvas.Polygon(ChartMath.Circle(dot.At, StrokeWidth + 2.5), dot.Color); + } + } +} diff --git a/examples/MAUI.Demo/Components/UI/BarChart.cs b/examples/MAUI.Demo/Components/UI/BarChart.cs new file mode 100644 index 0000000..d881ca1 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/BarChart.cs @@ -0,0 +1,130 @@ +using System.Globalization; + +namespace MAUI.Demo.Components.UI; + +// Bar chart — one bar per series for each category, side by side or stacked, upright or +// lying down. Bars are rounded at their outer end, and the hovered category gets a muted band +// and a tooltip. +// +// +// +// +public partial class BarChart : CartesianChart +{ + // Stacks the series on top of each other instead of side by side. + public static readonly BindableProperty IsStackedProperty = + BindableProperty.Create(nameof(IsStacked), typeof(bool), typeof(BarChart), false, + propertyChanged: (b, o, n) => ((BarChart)b).Refresh()); + + // Lays the bars down: categories run down the side, values along the bottom. + public static readonly BindableProperty IsHorizontalProperty = + BindableProperty.Create(nameof(IsHorizontal), typeof(bool), typeof(BarChart), false, + propertyChanged: (b, o, n) => ((BarChart)b).Refresh()); + + public static readonly BindableProperty CornerRadiusProperty = + BindableProperty.Create(nameof(CornerRadius), typeof(double), typeof(BarChart), 4d, + propertyChanged: (b, o, n) => ((BarChart)b).Refresh()); + + // The thickest a bar gets, however much room there is. + public static readonly BindableProperty MaxBarSizeProperty = + BindableProperty.Create(nameof(MaxBarSize), typeof(double), typeof(BarChart), 56d, + propertyChanged: (b, o, n) => ((BarChart)b).Refresh()); + + public bool IsStacked + { + get => (bool)GetValue(IsStackedProperty); + set => SetValue(IsStackedProperty, value); + } + + public bool IsHorizontal + { + get => (bool)GetValue(IsHorizontalProperty); + set => SetValue(IsHorizontalProperty, value); + } + + public double CornerRadius + { + get => (double)GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + + public double MaxBarSize + { + get => (double)GetValue(MaxBarSizeProperty); + set => SetValue(MaxBarSizeProperty, value); + } + + protected override void DrawChart(IChartCanvas canvas, Rect area) + { + var count = CategoryCount; + var series = Series; + double min = 0, max = 0; + for (var i = 0; i < count; i++) + { + double positive = 0, negative = 0; + foreach (var entry in series) + { + var value = ValueAt(entry, i); + if (!IsStacked) + { + positive = Math.Max(positive, value); + negative = Math.Min(negative, value); + } + else if (value >= 0) positive += value; + else negative += value; + } + max = Math.Max(max, positive); + min = Math.Min(min, negative); + } + + var frame = DrawAxes(canvas, area, min, max, bands: true, horizontal: IsHorizontal); + var plot = frame.Plot; + + // The cursor: a muted band behind the hovered category. + if (HoveredIndex >= 0) + { + var middle = frame.Category(HoveredIndex); + var band = IsHorizontal + ? new Rect(plot.Left, middle - frame.Band / 2, plot.Width, frame.Band) + : new Rect(middle - frame.Band / 2, plot.Top, frame.Band, plot.Height); + canvas.Polygon(ChartMath.RoundRect(band, 4, 4, 4, 4), ChartColor.Fade(ShellTheme.Get(ShellToken.Muted), 0.6)); + } + + const double gap = 4; + var slots = IsStacked ? 1 : Math.Max(1, series.Count); + var thickness = Math.Max(1, Math.Min(MaxBarSize, (frame.Band * 0.8 - gap * (slots - 1)) / slots)); + var group = thickness * slots + gap * (slots - 1); + for (var i = 0; i < count; i++) + { + var start = frame.Category(i) - group / 2; + double positive = 0, negative = 0; + for (var s = 0; s < series.Count; s++) + { + var full = ValueAt(series[s], i); + var value = full * Progress; + if (full == 0) continue; + var from = !IsStacked ? 0 : full >= 0 ? positive : negative; + var to = from + value; + if (IsStacked && full >= 0) positive = to; + else if (IsStacked) negative = to; + + // Only the outer end of a bar, or of a whole stack, is rounded. + var outer = !IsStacked || !series.Skip(s + 1).Any(next => ValueAt(next, i) != 0 && ValueAt(next, i) >= 0 == full >= 0); + var radius = outer ? CornerRadius : 0; + var offset = start + (IsStacked ? 0 : s * (thickness + gap)); + var a = frame.Value(from); + var b = frame.Value(to); + if (IsHorizontal) + { + var rect = new Rect(Math.Min(a, b), offset, Math.Abs(b - a), thickness); + canvas.Polygon(full >= 0 ? ChartMath.RoundRect(rect, 0, radius, radius, 0) : ChartMath.RoundRect(rect, radius, 0, 0, radius), SeriesColor(s)); + } + else + { + var rect = new Rect(offset, Math.Min(a, b), thickness, Math.Abs(b - a)); + canvas.Polygon(full >= 0 ? ChartMath.RoundRect(rect, radius, radius, 0, 0) : ChartMath.RoundRect(rect, 0, 0, radius, radius), SeriesColor(s)); + } + } + } + } +} diff --git a/examples/MAUI.Demo/Components/UI/Chart.cs b/examples/MAUI.Demo/Components/UI/Chart.cs new file mode 100644 index 0000000..34296b3 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Chart.cs @@ -0,0 +1,783 @@ +using System.Collections.ObjectModel; +using System.Globalization; + +namespace MAUI.Demo.Components.UI; + +// The shared core of the charts (bar-chart, line-chart, area-chart, pie-chart, radar-chart, +// radial-chart): the series model, the axes, legend and tooltip, the grow-in animation, and the +// canvas they draw on. Charts are drawn with MAUI's GraphicsView, so they need no package, and +// they take their colors from the theme (ShellToken.Chart1 to Chart5). +// To make a chart of your own, derive from ChartBase and draw in DrawChart. + +public enum ChartAlign { Start, Center, End } + +// The drawing calls a chart needs. Angles are in degrees, clockwise, 0 at three o'clock. +public interface IChartCanvas +{ + void Line(Point from, Point to, Color color, double width, bool dashed = false); + void Polyline(IReadOnlyList points, Color color, double width, bool closed = false); + void Polygon(IReadOnlyList points, Color color); + void Text(string text, Point at, double size, Color color, ChartAlign horizontal = ChartAlign.Start, ChartAlign vertical = ChartAlign.Start, bool bold = false); + Size Measure(string text, double size, bool bold = false); +} + +// A list of numbers that XAML can write as "186, 305, 237". +[System.ComponentModel.TypeConverter(typeof(ChartValuesConverter))] +public partial class ChartValues : List +{ + public ChartValues() + { + } + + public ChartValues(IEnumerable values) : base(values) + { + } + + public static implicit operator ChartValues(double[] values) => new(values); +} + +public partial class ChartValuesConverter : System.ComponentModel.TypeConverter +{ + public override bool CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, Type sourceType) => + sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); + + public override object? ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, CultureInfo? culture, object value) => + value is string text + ? new ChartValues(text.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries).Select(part => double.Parse(part, CultureInfo.InvariantCulture))) + : base.ConvertFrom(context, culture, value); +} + +// A list of category names that XAML can write as "Jan, Feb, Mar". +[System.ComponentModel.TypeConverter(typeof(ChartLabelsConverter))] +public partial class ChartLabels : List +{ + public ChartLabels() + { + } + + public ChartLabels(IEnumerable labels) : base(labels) + { + } + + public static implicit operator ChartLabels(string[] labels) => new(labels); +} + +public partial class ChartLabelsConverter : System.ComponentModel.TypeConverter +{ + public override bool CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, Type sourceType) => + sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); + + public override object? ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, CultureInfo? culture, object value) => + value is string text + ? new ChartLabels(text.Split(',').Select(part => part.Trim())) + : base.ConvertFrom(context, culture, value); +} + +// One named set of values. The chart redraws when a property is set; after changing the numbers +// inside Values, call the chart's Refresh(). +public partial class ChartSeries +{ + private string _name = string.Empty; + private ChartValues _values = new(); + private Color? _color; + + public string Name + { + get => _name; + set + { + _name = value ?? string.Empty; + Changed?.Invoke(); + } + } + + public ChartValues Values + { + get => _values; + set + { + _values = value ?? new ChartValues(); + Changed?.Invoke(); + } + } + + // Replaces the theme's chart color for this series. + public Color? Color + { + get => _color; + set + { + _color = value; + Changed?.Invoke(); + } + } + + internal Action? Changed { get; set; } +} + +// Geometry shared by the charts. Curves and arcs come back as points, so a canvas only has to +// draw straight segments. +public static class ChartMath +{ + public static Point Polar(Point center, double radius, double degrees) + { + var radians = degrees * Math.PI / 180; + return new Point(center.X + radius * Math.Cos(radians), center.Y + radius * Math.Sin(radians)); + } + + public static List Arc(Point center, double radius, double startDegrees, double sweepDegrees) + { + var steps = Math.Max(1, (int)Math.Ceiling(Math.Abs(sweepDegrees) / 3)); + var points = new List(steps + 1); + for (var i = 0; i <= steps; i++) points.Add(Polar(center, radius, startDegrees + sweepDegrees * i / steps)); + return points; + } + + // A pie slice, or a ring segment when inner is above zero. + public static List Sector(Point center, double inner, double outer, double startDegrees, double sweepDegrees) + { + var points = Arc(center, outer, startDegrees, sweepDegrees); + if (inner <= 0) points.Add(center); + else points.AddRange(Arc(center, inner, startDegrees + sweepDegrees, -sweepDegrees)); + return points; + } + + public static List Circle(Point center, double radius) => Arc(center, radius, 0, 360); + + // A rectangle with its own radius on each corner, clockwise from the top left. + public static List RoundRect(Rect rect, double topLeft, double topRight, double bottomRight, double bottomLeft) + { + var limit = Math.Min(rect.Width, rect.Height) / 2; + var points = new List(); + Corner(points, rect.Left, rect.Top, 1, 1, Math.Min(topLeft, limit), 180); + Corner(points, rect.Right, rect.Top, -1, 1, Math.Min(topRight, limit), 270); + Corner(points, rect.Right, rect.Bottom, -1, -1, Math.Min(bottomRight, limit), 0); + Corner(points, rect.Left, rect.Bottom, 1, -1, Math.Min(bottomLeft, limit), 90); + return points; + } + + private static void Corner(List points, double x, double y, int inX, int inY, double radius, double start) + { + if (radius < 0.5) + { + points.Add(new Point(x, y)); + return; + } + var center = new Point(x + inX * radius, y + inY * radius); + for (var i = 0; i <= 6; i++) points.Add(Polar(center, radius, start + 15 * i)); + } + + // Rounds a range out to steps of 1, 2 or 5 times a power of ten. + public static (double Min, double Max, double Step) NiceScale(double min, double max, int ticks) + { + if (max <= min) max = min + 1; + var rough = (max - min) / Math.Max(1, ticks); + var magnitude = Math.Pow(10, Math.Floor(Math.Log10(rough))); + var fraction = rough / magnitude; + var step = (fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10) * magnitude; + return (Math.Floor(min / step) * step, Math.Ceiling(max / step) * step, step); + } + + // A smooth curve through the points that never overshoots them (monotone cubic). + public static List Smooth(IReadOnlyList points) + { + var count = points.Count; + if (count < 3) return points.ToList(); + var slope = new double[count - 1]; + var tangent = new double[count]; + for (var i = 0; i < count - 1; i++) + { + var dx = points[i + 1].X - points[i].X; + slope[i] = dx == 0 ? 0 : (points[i + 1].Y - points[i].Y) / dx; + } + tangent[0] = slope[0]; + tangent[count - 1] = slope[count - 2]; + for (var i = 1; i < count - 1; i++) tangent[i] = slope[i - 1] * slope[i] <= 0 ? 0 : (slope[i - 1] + slope[i]) / 2; + for (var i = 0; i < count - 1; i++) + { + if (slope[i] == 0) + { + tangent[i] = 0; + tangent[i + 1] = 0; + continue; + } + var a = tangent[i] / slope[i]; + var b = tangent[i + 1] / slope[i]; + var length = a * a + b * b; + if (length <= 9) continue; + var scale = 3 / Math.Sqrt(length); + tangent[i] = scale * a * slope[i]; + tangent[i + 1] = scale * b * slope[i]; + } + + var curve = new List { points[0] }; + for (var i = 0; i < count - 1; i++) + { + var width = points[i + 1].X - points[i].X; + for (var step = 1; step <= 12; step++) + { + var t = step / 12.0; + var t2 = t * t; + var t3 = t2 * t; + var y = (2 * t3 - 3 * t2 + 1) * points[i].Y + (t3 - 2 * t2 + t) * width * tangent[i] + + (-2 * t3 + 3 * t2) * points[i + 1].Y + (t3 - t2) * width * tangent[i + 1]; + curve.Add(new Point(points[i].X + width * t, y)); + } + } + return curve; + } +} + +// Where a bar, line or area chart's plot sits and how values and categories map onto it. +public sealed class ChartFrame +{ + public ChartFrame(Rect plot, double min, double max, int count, bool bands, bool horizontal) + { + Plot = plot; + Min = min; + Max = max; + Count = count; + Bands = bands; + Horizontal = horizontal; + } + + public Rect Plot { get; } + public double Min { get; } + public double Max { get; } + public int Count { get; } + // Bars get a band each; lines and areas run from edge to edge. + public bool Bands { get; } + public bool Horizontal { get; } + + private double Start => Horizontal ? Plot.Top : Plot.Left; + private double Length => Horizontal ? Plot.Height : Plot.Width; + + // The room one category takes along the category axis. + public double Band => Length / Math.Max(1, Bands ? Count : Count - 1); + + // Where a value sits along the value axis. + public double Value(double value) + { + var t = Max > Min ? (value - Min) / (Max - Min) : 0; + return Horizontal ? Plot.Left + t * Plot.Width : Plot.Bottom - t * Plot.Height; + } + + // The middle of a category along the category axis. + public double Category(int index) + { + if (Bands) return Start + (index + 0.5) * Band; + return Count <= 1 ? Start + Length / 2 : Start + index * Band; + } + + // The category under a point, or -1 outside the plot. + public int IndexAt(Point point) + { + if (Count == 0 || point.X < Plot.Left - 12 || point.X > Plot.Right + 12 || point.Y < Plot.Top - 12 || point.Y > Plot.Bottom + 12) return -1; + var along = (Horizontal ? point.Y : point.X) - Start; + var index = Bands ? (int)Math.Floor(along / Band) : (int)Math.Round(along / Band); + return Math.Clamp(index, 0, Count - 1); + } +} + +internal static class ChartColor +{ + public static Color Fade(Color color, double opacity) => color.WithAlpha((float)(color.Alpha * opacity)); +} + +// IChartCanvas on a MAUI ICanvas. +internal sealed class ChartCanvas : IChartCanvas +{ + private readonly ICanvas _canvas; + + public ChartCanvas(ICanvas canvas) => _canvas = canvas; + + public void Line(Point from, Point to, Color color, double width, bool dashed = false) + { + _canvas.StrokeColor = color; + _canvas.StrokeSize = (float)width; + _canvas.StrokeLineCap = LineCap.Butt; + _canvas.StrokeDashPattern = dashed ? new float[] { 3, 3 } : null; + _canvas.DrawLine((float)from.X, (float)from.Y, (float)to.X, (float)to.Y); + } + + public void Polyline(IReadOnlyList points, Color color, double width, bool closed = false) + { + if (points.Count < 2) return; + _canvas.StrokeColor = color; + _canvas.StrokeSize = (float)width; + _canvas.StrokeLineCap = LineCap.Round; + _canvas.StrokeLineJoin = LineJoin.Round; + _canvas.StrokeDashPattern = null; + _canvas.DrawPath(Path(points, closed)); + } + + public void Polygon(IReadOnlyList points, Color color) + { + if (points.Count < 3) return; + _canvas.FillColor = color; + _canvas.FillPath(Path(points, closed: true)); + } + + public void Text(string text, Point at, double size, Color color, ChartAlign horizontal = ChartAlign.Start, ChartAlign vertical = ChartAlign.Start, bool bold = false) + { + if (string.IsNullOrEmpty(text)) return; + var measured = Measure(text, size, bold); + // The platform aligns the text inside a roomy box around the anchor, so a measurement + // that is a little off never clips or shifts it. + var width = measured.Width + 24; + var height = measured.Height * 2; + var x = horizontal == ChartAlign.Start ? at.X : horizontal == ChartAlign.Center ? at.X - width / 2 : at.X - width; + var middle = vertical == ChartAlign.Start ? at.Y + measured.Height / 2 : vertical == ChartAlign.Center ? at.Y : at.Y - measured.Height / 2; + _canvas.Font = bold ? Microsoft.Maui.Graphics.Font.DefaultBold : Microsoft.Maui.Graphics.Font.Default; + _canvas.FontSize = (float)size; + _canvas.FontColor = color; + _canvas.DrawString(text, (float)x, (float)(middle - height / 2), (float)width, (float)height, + horizontal == ChartAlign.Start ? HorizontalAlignment.Left : horizontal == ChartAlign.Center ? HorizontalAlignment.Center : HorizontalAlignment.Right, + VerticalAlignment.Center, TextFlow.OverflowBounds); + } + + public Size Measure(string text, double size, bool bold = false) + { + if (string.IsNullOrEmpty(text)) return Size.Zero; + var measured = _canvas.GetStringSize(text, bold ? Microsoft.Maui.Graphics.Font.DefaultBold : Microsoft.Maui.Graphics.Font.Default, (float)size); + // Platforms disagree about the height of a line, so it comes from the font size. + return new Size(Math.Ceiling(measured.Width) + 1, Math.Ceiling(size * 1.35)); + } + + private static PathF Path(IReadOnlyList points, bool closed) + { + var path = new PathF(); + path.MoveTo((float)points[0].X, (float)points[0].Y); + for (var i = 1; i < points.Count; i++) path.LineTo((float)points[i].X, (float)points[i].Y); + if (closed) path.Close(); + return path; + } +} + +// What every chart shares: its series and labels, the legend, the tooltip under the pointer and +// the grow-in animation. Give a chart a height. +[ContentProperty(nameof(Series))] +public abstract partial class ChartBase : ContentView, IDrawable +{ + // The category names along the axis, or the slice names of a pie. + public static readonly BindableProperty LabelsProperty = + BindableProperty.Create(nameof(Labels), typeof(ChartLabels), typeof(ChartBase), null, + propertyChanged: (b, o, n) => ((ChartBase)b).Refresh()); + + public static readonly BindableProperty ShowLegendProperty = + BindableProperty.Create(nameof(ShowLegend), typeof(bool), typeof(ChartBase), true, + propertyChanged: (b, o, n) => ((ChartBase)b).Refresh()); + + // Shows the values under the pointer (or the finger). + public static readonly BindableProperty ShowTooltipProperty = + BindableProperty.Create(nameof(ShowTooltip), typeof(bool), typeof(ChartBase), true, + propertyChanged: (b, o, n) => ((ChartBase)b).Refresh()); + + // Grows the chart in when it first appears. + public static readonly BindableProperty IsAnimatedProperty = + BindableProperty.Create(nameof(IsAnimated), typeof(bool), typeof(ChartBase), true, + propertyChanged: (b, o, n) => ((ChartBase)b).Refresh()); + + // A .NET number format for axis and tooltip values. + public static readonly BindableProperty ValueFormatProperty = + BindableProperty.Create(nameof(ValueFormat), typeof(string), typeof(ChartBase), "#,0.##", + propertyChanged: (b, o, n) => ((ChartBase)b).Refresh()); + + public static readonly BindableProperty EmptyTextProperty = + BindableProperty.Create(nameof(EmptyText), typeof(string), typeof(ChartBase), "No data", + propertyChanged: (b, o, n) => ((ChartBase)b).Refresh()); + + // What the chart shows, for screen readers. + public static readonly BindableProperty DescriptionProperty = + BindableProperty.Create(nameof(Description), typeof(string), typeof(ChartBase), "", + propertyChanged: (b, o, n) => ((ChartBase)b).Refresh()); + + public ChartLabels? Labels + { + get => (ChartLabels?)GetValue(LabelsProperty); + set => SetValue(LabelsProperty, value); + } + + public bool ShowLegend + { + get => (bool)GetValue(ShowLegendProperty); + set => SetValue(ShowLegendProperty, value); + } + + public bool ShowTooltip + { + get => (bool)GetValue(ShowTooltipProperty); + set => SetValue(ShowTooltipProperty, value); + } + + public bool IsAnimated + { + get => (bool)GetValue(IsAnimatedProperty); + set => SetValue(IsAnimatedProperty, value); + } + + public string ValueFormat + { + get => (string)GetValue(ValueFormatProperty); + set => SetValue(ValueFormatProperty, value); + } + + public string EmptyText + { + get => (string)GetValue(EmptyTextProperty); + set => SetValue(EmptyTextProperty, value); + } + + public string Description + { + get => (string)GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public IList Series => _series; + + private const string GrowAnimation = "ChartGrow"; + + private readonly ObservableCollection _series = new(); + private readonly GraphicsView _view; + private Point _pointer; + private int _hover = -1; + private double _progress = 1; + + protected ChartBase() + { + _view = new GraphicsView { Drawable = this, BackgroundColor = Colors.Transparent }; + _view.MoveHoverInteraction += (_, e) => Hover(First(e.Touches)); + _view.EndHoverInteraction += (_, _) => Hover(null); + // A touch has no hover: a tap or a drag shows the tooltip instead. + _view.StartInteraction += (_, e) => Hover(First(e.Touches)); + _view.DragInteraction += (_, e) => Hover(First(e.Touches)); + _series.CollectionChanged += (_, _) => + { + foreach (var series in _series) series.Changed = Refresh; + Refresh(); + }; + Content = _view; + MinimumHeightRequest = 160; + Loaded += (_, _) => + { + ShellTheme.ThemeChanged += OnThemeChanged; + PlayAnimation(); + }; + Unloaded += (_, _) => ShellTheme.ThemeChanged -= OnThemeChanged; + } + + // Redraws the chart. Call it after changing the numbers inside a series' Values. + public void Refresh() => _view?.Invalidate(); + + protected override void OnPropertyChanged(string? propertyName = null) + { + base.OnPropertyChanged(propertyName); + if (propertyName == DescriptionProperty.PropertyName) + SemanticProperties.SetDescription(this, Description ?? string.Empty); + } + + // Grows the chart in again. + public void PlayAnimation() + { + this.AbortAnimation(GrowAnimation); + if (!IsAnimated) + { + _progress = 1; + Refresh(); + return; + } + new Animation(value => + { + _progress = value; + Refresh(); + }, 0, 1, Easing.CubicOut).Commit(this, GrowAnimation, 16, 600); + } + + void IDrawable.Draw(ICanvas canvas, RectF dirtyRect) => + Paint(new ChartCanvas(canvas), new Size(dirtyRect.Width, dirtyRect.Height)); + + private static Point? First(PointF[] touches) => touches.Length > 0 ? new Point(touches[0].X, touches[0].Y) : null; + + // The theme's chart colors in order; past the fifth they repeat, fainter. + protected static Color Palette(int index) + { + var color = ShellTheme.Get(ShellToken.Chart1 + index % 5); + return index < 5 ? color : ChartColor.Fade(color, 0.6); + } + + protected Color SeriesColor(int index) => Series[index].Color ?? Palette(index); + + protected int CategoryCount => Math.Max(Labels?.Count ?? 0, Series.Count == 0 ? 0 : Series.Max(s => s.Values.Count)); + + protected string LabelAt(int index) => + Labels != null && index < Labels.Count ? Labels[index] : (index + 1).ToString(CultureInfo.CurrentCulture); + + protected static double ValueAt(ChartSeries series, int index) => index < series.Values.Count ? series.Values[index] : 0; + + protected string Format(double value) => + value.ToString(string.IsNullOrEmpty(ValueFormat) ? "#,0.##" : ValueFormat, CultureInfo.CurrentCulture); + + // 0 to 1 while the chart animates in, then 1. + protected double Progress => IsAnimated ? _progress : 1; + + // The category, slice or ring under the pointer, or -1. + protected int HoveredIndex => ShowTooltip ? _hover : -1; + + protected virtual bool HasData => Series.Any(s => s.Values.Count > 0); + + // Draws the chart itself into `area`; the legend and tooltip are added around it. + protected abstract void DrawChart(IChartCanvas canvas, Rect area); + + protected virtual int HitTest(Point point) => -1; + + protected virtual List<(Color Color, string Name)> LegendEntries() => + Series.Select((s, i) => (SeriesColor(i), s.Name)).Where(entry => !string.IsNullOrEmpty(entry.Name)).ToList(); + + protected virtual (string Title, List<(Color Color, string Name, string Value)> Rows) Tooltip(int index) => + (LabelAt(index), Series.Select((s, i) => (SeriesColor(i), s.Name, Format(ValueAt(s, index)))).ToList()); + + private void OnThemeChanged(object? sender, EventArgs e) => Refresh(); + + private void Hover(Point? point) + { + var index = point is { } at ? HitTest(at) : -1; + if (index < 0 && _hover < 0) return; + _pointer = point ?? _pointer; + _hover = index; + Refresh(); + } + + private void Paint(IChartCanvas canvas, Size size) + { + if (size.Width < 8 || size.Height < 8) return; + if (!HasData) + { + canvas.Text(EmptyText ?? string.Empty, new Point(size.Width / 2, size.Height / 2), 14, + ShellTheme.Get(ShellToken.MutedForeground), ChartAlign.Center, ChartAlign.Center); + return; + } + + var legend = ShowLegend ? LegendEntries() : new List<(Color Color, string Name)>(); + var legendHeight = legend.Count > 0 ? DrawLegend(canvas, legend, size, draw: false) : 0; + DrawChart(canvas, new Rect(0, 0, size.Width, size.Height - legendHeight)); + if (legend.Count > 0) DrawLegend(canvas, legend, size, draw: true); + if (HoveredIndex >= 0) DrawTooltip(canvas, size); + } + + // justify-center gap-4 text-xs: a 8px rounded square and the name per entry, wrapped into + // as many centered rows as it takes. Returns the height the legend needs. + private static double DrawLegend(IChartCanvas canvas, List<(Color Color, string Name)> entries, Size size, bool draw) + { + const double fontSize = 12, mark = 8, markGap = 6, gap = 16, rowHeight = 20, top = 12; + var widths = entries.Select(e => mark + markGap + canvas.Measure(e.Name, fontSize).Width).ToList(); + var rows = new List> { new() }; + var used = 0.0; + for (var i = 0; i < entries.Count; i++) + { + var next = used == 0 ? widths[i] : used + gap + widths[i]; + if (used > 0 && next > size.Width) + { + rows.Add(new List()); + next = widths[i]; + } + rows[^1].Add(i); + used = next; + } + + var height = top + rows.Count * rowHeight; + if (!draw) return height; + var text = ShellTheme.Get(ShellToken.Foreground); + var y = size.Height - rows.Count * rowHeight; + foreach (var row in rows) + { + var x = (size.Width - row.Sum(i => widths[i]) - gap * (row.Count - 1)) / 2; + foreach (var i in row) + { + var middle = y + rowHeight / 2; + canvas.Polygon(ChartMath.RoundRect(new Rect(x, middle - mark / 2, mark, mark), 2, 2, 2, 2), entries[i].Color); + canvas.Text(entries[i].Name, new Point(x + mark + markGap, middle), fontSize, text, ChartAlign.Start, ChartAlign.Center); + x += widths[i] + gap; + } + y += rowHeight; + } + return height; + } + + // rounded-lg border bg-background px-2.5 py-1.5 text-xs: a title, then a colored square, + // the name and the value per row. It follows the pointer and stays inside the chart. + private void DrawTooltip(IChartCanvas canvas, Size size) + { + var (title, rows) = Tooltip(_hover); + if (rows.Count == 0) return; + const double fontSize = 12, pad = 10, rowHeight = 18, mark = 10; + var hasTitle = !string.IsNullOrEmpty(title); + var names = rows.Max(r => canvas.Measure(r.Name, fontSize).Width); + var values = rows.Max(r => canvas.Measure(r.Value, fontSize, bold: true).Width); + var width = Math.Max(128, pad * 2 + mark + 6 + names + 16 + values); + if (hasTitle) width = Math.Max(width, pad * 2 + canvas.Measure(title, fontSize, bold: true).Width); + var height = 16 + rows.Count * rowHeight + (hasTitle ? rowHeight + 2 : 0); + + var x = _pointer.X + 14; + if (x + width > size.Width) x = _pointer.X - 14 - width; + x = Math.Clamp(x, 0, Math.Max(0, size.Width - width)); + var y = _pointer.Y + 14; + if (y + height > size.Height) y = _pointer.Y - 14 - height; + y = Math.Clamp(y, 0, Math.Max(0, size.Height - height)); + + canvas.Polygon(ChartMath.RoundRect(new Rect(x, y, width, height), 8, 8, 8, 8), ShellTheme.Get(ShellToken.Border)); + canvas.Polygon(ChartMath.RoundRect(new Rect(x + 1, y + 1, width - 2, height - 2), 7, 7, 7, 7), ShellTheme.Get(ShellToken.Background)); + var foreground = ShellTheme.Get(ShellToken.Foreground); + var muted = ShellTheme.Get(ShellToken.MutedForeground); + var top = y + 8; + if (hasTitle) + { + canvas.Text(title, new Point(x + pad, top + rowHeight / 2), fontSize, foreground, ChartAlign.Start, ChartAlign.Center, bold: true); + top += rowHeight + 2; + } + foreach (var row in rows) + { + var middle = top + rowHeight / 2; + canvas.Polygon(ChartMath.RoundRect(new Rect(x + pad, middle - mark / 2, mark, mark), 2, 2, 2, 2), row.Color); + canvas.Text(row.Name, new Point(x + pad + mark + 6, middle), fontSize, muted, ChartAlign.Start, ChartAlign.Center); + canvas.Text(row.Value, new Point(x + width - pad, middle), fontSize, foreground, ChartAlign.End, ChartAlign.Center, bold: true); + top += rowHeight; + } + } + + // A large value over a small label, for the middle of a donut or radial chart. + protected static void DrawCenterText(IChartCanvas canvas, Point center, string? value, string? label) + { + var hasValue = !string.IsNullOrEmpty(value); + var hasLabel = !string.IsNullOrEmpty(label); + if (hasValue) + canvas.Text(value!, new Point(center.X, center.Y - (hasLabel ? 8 : 0)), 24, ShellTheme.Get(ShellToken.Foreground), ChartAlign.Center, ChartAlign.Center, bold: true); + if (hasLabel) + canvas.Text(label!, new Point(center.X, center.Y + (hasValue ? 14 : 0)), 12, ShellTheme.Get(ShellToken.MutedForeground), ChartAlign.Center, ChartAlign.Center); + } +} + +// What bar, line and area charts share: a value axis, a category axis and a grid. +public abstract partial class CartesianChart : ChartBase +{ + public static readonly BindableProperty ShowGridProperty = + BindableProperty.Create(nameof(ShowGrid), typeof(bool), typeof(CartesianChart), true, + propertyChanged: (b, o, n) => ((CartesianChart)b).Refresh()); + + // The labels under the plot. + public static readonly BindableProperty ShowXAxisProperty = + BindableProperty.Create(nameof(ShowXAxis), typeof(bool), typeof(CartesianChart), true, + propertyChanged: (b, o, n) => ((CartesianChart)b).Refresh()); + + // The labels beside the plot. + public static readonly BindableProperty ShowYAxisProperty = + BindableProperty.Create(nameof(ShowYAxis), typeof(bool), typeof(CartesianChart), true, + propertyChanged: (b, o, n) => ((CartesianChart)b).Refresh()); + + // The ends of the value axis; by default they fit the data and include zero. + public static readonly BindableProperty MinimumProperty = + BindableProperty.Create(nameof(Minimum), typeof(double), typeof(CartesianChart), double.NaN, + propertyChanged: (b, o, n) => ((CartesianChart)b).Refresh()); + + public static readonly BindableProperty MaximumProperty = + BindableProperty.Create(nameof(Maximum), typeof(double), typeof(CartesianChart), double.NaN, + propertyChanged: (b, o, n) => ((CartesianChart)b).Refresh()); + + public bool ShowGrid + { + get => (bool)GetValue(ShowGridProperty); + set => SetValue(ShowGridProperty, value); + } + + public bool ShowXAxis + { + get => (bool)GetValue(ShowXAxisProperty); + set => SetValue(ShowXAxisProperty, value); + } + + public bool ShowYAxis + { + get => (bool)GetValue(ShowYAxisProperty); + set => SetValue(ShowYAxisProperty, value); + } + + public double Minimum + { + get => (double)GetValue(MinimumProperty); + set => SetValue(MinimumProperty, value); + } + + public double Maximum + { + get => (double)GetValue(MaximumProperty); + set => SetValue(MaximumProperty, value); + } + + // The frame of the last draw, for hit testing. + protected ChartFrame? LastFrame { get; private set; } + + protected override int HitTest(Point point) => LastFrame?.IndexAt(point) ?? -1; + + // Draws the grid and the axis labels into `area` and returns how values and categories map + // onto the plot. The value axis always includes zero unless Minimum or Maximum say otherwise. + protected ChartFrame DrawAxes(IChartCanvas canvas, Rect area, double dataMin, double dataMax, bool bands, bool horizontal = false) + { + const double fontSize = 12; + var muted = ShellTheme.Get(ShellToken.MutedForeground); + var line = ShellTheme.Get(ShellToken.Border); + var count = CategoryCount; + var (low, high, step) = ChartMath.NiceScale( + double.IsNaN(Minimum) ? Math.Min(0, dataMin) : Minimum, + double.IsNaN(Maximum) ? Math.Max(0, dataMax) : Maximum, 5); + if (!double.IsNaN(Minimum)) low = Minimum; + if (!double.IsNaN(Maximum)) high = Maximum; + var ticks = new List(); + for (var tick = Math.Ceiling(low / step) * step; tick <= high + step / 1000; tick += step) + ticks.Add(Math.Abs(tick) < step / 1000 ? 0 : tick); + + var showValues = horizontal ? ShowXAxis : ShowYAxis; + var showCategories = horizontal ? ShowYAxis : ShowXAxis; + var labels = Enumerable.Range(0, count).Select(LabelAt).ToList(); + double left = 0; + if (horizontal && showCategories) left = labels.Max(label => canvas.Measure(label, fontSize).Width) + 8; + if (!horizontal && showValues) left = ticks.Max(tick => canvas.Measure(Format(tick), fontSize).Width) + 8; + // An edge-to-edge axis needs room for half of its first and last label. + if (!bands && !horizontal) left = Math.Max(left, 12); + var right = horizontal ? 16 : bands ? 0 : 12; + var bottom = (horizontal ? showValues : showCategories) ? 24 : 0; + var plot = new Rect(area.Left + left, area.Top + 8, Math.Max(1, area.Width - left - right), Math.Max(1, area.Height - 8 - bottom)); + var frame = new ChartFrame(plot, low, high, count, bands, horizontal); + + foreach (var tick in ticks) + { + var at = frame.Value(tick); + // Half a pixel off the grid keeps a 1px line sharp. + var sharp = Math.Round(at) + 0.5; + if (ShowGrid) + { + if (horizontal) canvas.Line(new Point(sharp, plot.Top), new Point(sharp, plot.Bottom), line, 1); + else canvas.Line(new Point(plot.Left, sharp), new Point(plot.Right, sharp), line, 1); + } + if (!showValues) continue; + if (horizontal) canvas.Text(Format(tick), new Point(at, plot.Bottom + 8), fontSize, muted, ChartAlign.Center); + else canvas.Text(Format(tick), new Point(plot.Left - 8, at), fontSize, muted, ChartAlign.End, ChartAlign.Center); + } + + if (showCategories && horizontal) + { + for (var i = 0; i < count; i++) + canvas.Text(labels[i], new Point(plot.Left - 8, frame.Category(i)), fontSize, muted, ChartAlign.End, ChartAlign.Center); + } + else if (showCategories) + { + // Labels that would run into each other are thinned out. + var widest = labels.Max(label => canvas.Measure(label, fontSize).Width); + var every = Math.Max(1, (int)Math.Ceiling((widest + 8) / Math.Max(1, frame.Band))); + for (var i = 0; i < count; i += every) + canvas.Text(labels[i], new Point(frame.Category(i), plot.Bottom + 8), fontSize, muted, ChartAlign.Center); + } + + LastFrame = frame; + return frame; + } +} diff --git a/examples/MAUI.Demo/Components/UI/LineChart.cs b/examples/MAUI.Demo/Components/UI/LineChart.cs new file mode 100644 index 0000000..04391e2 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/LineChart.cs @@ -0,0 +1,72 @@ +using System.Globalization; + +namespace MAUI.Demo.Components.UI; + +// Line chart — one line per series from edge to edge of the plot, smooth by default. The +// hovered category gets a hairline, a dot on each line and a tooltip. +// +// +// +// +public partial class LineChart : CartesianChart +{ + // Curves the line through its points; false joins them with straight segments. + public static readonly BindableProperty IsSmoothProperty = + BindableProperty.Create(nameof(IsSmooth), typeof(bool), typeof(LineChart), true, + propertyChanged: (b, o, n) => ((LineChart)b).Refresh()); + + // A dot on every point. + public static readonly BindableProperty ShowDotsProperty = + BindableProperty.Create(nameof(ShowDots), typeof(bool), typeof(LineChart), false, + propertyChanged: (b, o, n) => ((LineChart)b).Refresh()); + + public static readonly BindableProperty StrokeWidthProperty = + BindableProperty.Create(nameof(StrokeWidth), typeof(double), typeof(LineChart), 2d, + propertyChanged: (b, o, n) => ((LineChart)b).Refresh()); + + public bool IsSmooth + { + get => (bool)GetValue(IsSmoothProperty); + set => SetValue(IsSmoothProperty, value); + } + + public bool ShowDots + { + get => (bool)GetValue(ShowDotsProperty); + set => SetValue(ShowDotsProperty, value); + } + + public double StrokeWidth + { + get => (double)GetValue(StrokeWidthProperty); + set => SetValue(StrokeWidthProperty, value); + } + + protected override void DrawChart(IChartCanvas canvas, Rect area) + { + var all = Series.SelectMany(s => s.Values).DefaultIfEmpty(0).ToList(); + var frame = DrawAxes(canvas, area, all.Min(), all.Max(), bands: false); + var plot = frame.Plot; + var hovered = HoveredIndex; + if (hovered >= 0) + { + // The cursor: a hairline through the hovered category. + var x = Math.Round(frame.Category(hovered)) + 0.5; + canvas.Line(new Point(x, plot.Top), new Point(x, plot.Bottom), ShellTheme.Get(ShellToken.Border), 1); + } + + for (var s = 0; s < Series.Count; s++) + { + var values = Series[s].Values; + if (values.Count == 0) continue; + var color = SeriesColor(s); + var points = values.Select((value, i) => new Point(frame.Category(i), frame.Value(value * Progress))).ToList(); + if (points.Count > 1) canvas.Polyline(IsSmooth ? ChartMath.Smooth(points) : points, color, StrokeWidth); + if (ShowDots || points.Count == 1) + foreach (var point in points) canvas.Polygon(ChartMath.Circle(point, StrokeWidth + 1.5), color); + if (hovered < 0 || hovered >= points.Count) continue; + canvas.Polygon(ChartMath.Circle(points[hovered], StrokeWidth + 4), ShellTheme.Get(ShellToken.Background)); + canvas.Polygon(ChartMath.Circle(points[hovered], StrokeWidth + 2.5), color); + } + } +} diff --git a/examples/MAUI.Demo/Components/UI/PieChart.cs b/examples/MAUI.Demo/Components/UI/PieChart.cs new file mode 100644 index 0000000..75ce62c --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/PieChart.cs @@ -0,0 +1,147 @@ +using System.Globalization; + +namespace MAUI.Demo.Components.UI; + +// Pie chart — the first series' values as slices, clockwise from twelve o'clock, named by +// Labels and colored from the theme's chart colors in order. The hovered slice stands out and +// shows its value and share. DonutChart is the same with a hole for a value and a label. +// +// +// +// +// +// +public partial class PieChart : ChartBase +{ + // The hole, as a share of the radius: 0 is a pie, 0.6 a donut. + public static readonly BindableProperty InnerRadiusProperty = + BindableProperty.Create(nameof(InnerRadius), typeof(double), typeof(PieChart), 0d, + propertyChanged: (b, o, n) => ((PieChart)b).Refresh()); + + // Degrees of space between slices. + public static readonly BindableProperty GapProperty = + BindableProperty.Create(nameof(Gap), typeof(double), typeof(PieChart), 1d, + propertyChanged: (b, o, n) => ((PieChart)b).Refresh()); + + // Writes each slice's value outside it. + public static readonly BindableProperty ShowLabelsProperty = + BindableProperty.Create(nameof(ShowLabels), typeof(bool), typeof(PieChart), false, + propertyChanged: (b, o, n) => ((PieChart)b).Refresh()); + + // Large text in the hole of a donut. + public static readonly BindableProperty CenterValueProperty = + BindableProperty.Create(nameof(CenterValue), typeof(string), typeof(PieChart), "", + propertyChanged: (b, o, n) => ((PieChart)b).Refresh()); + + // Small muted text under CenterValue. + public static readonly BindableProperty CenterLabelProperty = + BindableProperty.Create(nameof(CenterLabel), typeof(string), typeof(PieChart), "", + propertyChanged: (b, o, n) => ((PieChart)b).Refresh()); + + public double InnerRadius + { + get => (double)GetValue(InnerRadiusProperty); + set => SetValue(InnerRadiusProperty, value); + } + + public double Gap + { + get => (double)GetValue(GapProperty); + set => SetValue(GapProperty, value); + } + + public bool ShowLabels + { + get => (bool)GetValue(ShowLabelsProperty); + set => SetValue(ShowLabelsProperty, value); + } + + public string CenterValue + { + get => (string)GetValue(CenterValueProperty); + set => SetValue(CenterValueProperty, value); + } + + public string CenterLabel + { + get => (string)GetValue(CenterLabelProperty); + set => SetValue(CenterLabelProperty, value); + } + + // The slices of the last draw, for hit testing. + private readonly List<(double Start, double Sweep)> _slices = new(); + private Point _center; + private double _inner; + private double _outer; + + private List Slices() => + Series.Count == 0 ? new List() : Series[0].Values.Select(value => Math.Max(0, value)).ToList(); + + protected override bool HasData => Slices().Any(value => value > 0); + + protected override void DrawChart(IChartCanvas canvas, Rect area) + { + var values = Slices(); + var total = values.Sum(); + _slices.Clear(); + _center = new Point(area.Left + area.Width / 2, area.Top + area.Height / 2); + _outer = Math.Max(4, Math.Min(area.Width, area.Height) / 2 - (ShowLabels ? 30 : 10)); + _inner = _outer * Math.Clamp(InnerRadius, 0, 0.95); + var gap = values.Count(value => value > 0) > 1 ? Gap : 0; + var start = -90.0; + for (var i = 0; i < values.Count; i++) + { + var sweep = values[i] / total * 360 * Progress; + _slices.Add((start, sweep)); + if (sweep > gap) + { + // The hovered slice stands out a little. + var grow = HoveredIndex == i ? 6 : 0; + canvas.Polygon(ChartMath.Sector(_center, _inner, _outer + grow, start + gap / 2, sweep - gap), Palette(i)); + if (ShowLabels && Progress >= 1) + canvas.Text(Format(values[i]), ChartMath.Polar(_center, _outer + 16, start + sweep / 2), 12, + ShellTheme.Get(ShellToken.Foreground), ChartAlign.Center, ChartAlign.Center); + } + start += sweep; + } + if (_inner > 0) DrawCenterText(canvas, _center, CenterValue, CenterLabel); + } + + protected override int HitTest(Point point) + { + var dx = point.X - _center.X; + var dy = point.Y - _center.Y; + var distance = Math.Sqrt(dx * dx + dy * dy); + if (distance < _inner || distance > _outer + 6) return -1; + // Clockwise from twelve o'clock, like the slices. + var angle = (Math.Atan2(dy, dx) * 180 / Math.PI + 90 + 360) % 360; + for (var i = 0; i < _slices.Count; i++) + { + var from = _slices[i].Start + 90; + if (angle >= from && angle < from + _slices[i].Sweep) return i; + } + return -1; + } + + protected override List<(Color Color, string Name)> LegendEntries() => + Slices().Select((_, i) => (Palette(i), LabelAt(i))).ToList(); + + protected override (string Title, List<(Color Color, string Name, string Value)> Rows) Tooltip(int index) + { + var values = Slices(); + var share = values[index] / values.Sum(); + return (string.Empty, new List<(Color Color, string Name, string Value)> + { + (Palette(index), LabelAt(index), $"{Format(values[index])} ({share.ToString("P0", CultureInfo.CurrentCulture)})") + }); + } +} + +// A pie with a hole — InnerRadius starts at 0.6, with CenterValue and CenterLabel in the middle. +public partial class DonutChart : PieChart +{ + public DonutChart() + { + InnerRadius = 0.6; + } +} diff --git a/examples/MAUI.Demo/Components/UI/RadarChart.cs b/examples/MAUI.Demo/Components/UI/RadarChart.cs new file mode 100644 index 0000000..6785a99 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/RadarChart.cs @@ -0,0 +1,105 @@ +using System.Globalization; + +namespace MAUI.Demo.Components.UI; + +// Radar chart — one spoke per category around a ringed grid, and a filled shape per series. +// It needs at least three categories. The hovered spoke shows every series' value there. +// +// +// +// +public partial class RadarChart : ChartBase +{ + // The value at the outer ring; by default it fits the data. + public static readonly BindableProperty MaximumProperty = + BindableProperty.Create(nameof(Maximum), typeof(double), typeof(RadarChart), double.NaN, + propertyChanged: (b, o, n) => ((RadarChart)b).Refresh()); + + public static readonly BindableProperty FillOpacityProperty = + BindableProperty.Create(nameof(FillOpacity), typeof(double), typeof(RadarChart), 0.5, + propertyChanged: (b, o, n) => ((RadarChart)b).Refresh()); + + // A dot on every point. + public static readonly BindableProperty ShowDotsProperty = + BindableProperty.Create(nameof(ShowDots), typeof(bool), typeof(RadarChart), false, + propertyChanged: (b, o, n) => ((RadarChart)b).Refresh()); + + public double Maximum + { + get => (double)GetValue(MaximumProperty); + set => SetValue(MaximumProperty, value); + } + + public double FillOpacity + { + get => (double)GetValue(FillOpacityProperty); + set => SetValue(FillOpacityProperty, value); + } + + public bool ShowDots + { + get => (bool)GetValue(ShowDotsProperty); + set => SetValue(ShowDotsProperty, value); + } + + private Point _center; + private double _radius; + + // A radar needs at least three axes. + protected override bool HasData => base.HasData && CategoryCount >= 3; + + protected override void DrawChart(IChartCanvas canvas, Rect area) + { + var count = CategoryCount; + var line = ShellTheme.Get(ShellToken.Border); + var muted = ShellTheme.Get(ShellToken.MutedForeground); + _center = new Point(area.Left + area.Width / 2, area.Top + area.Height / 2); + _radius = Math.Max(4, Math.Min(area.Width, area.Height) / 2 - 32); + var max = double.IsNaN(Maximum) + ? ChartMath.NiceScale(0, Series.SelectMany(s => s.Values).DefaultIfEmpty(1).Max(), 4).Max + : Maximum; + if (max <= 0) max = 1; + double Angle(int i) => -90 + 360.0 * i / count; + + // Four rings and a spoke per axis. + for (var level = 1; level <= 4; level++) + canvas.Polyline(Enumerable.Range(0, count).Select(i => ChartMath.Polar(_center, _radius * level / 4, Angle(i))).ToList(), line, 1, closed: true); + for (var i = 0; i < count; i++) + { + canvas.Line(_center, ChartMath.Polar(_center, _radius, Angle(i)), line, 1); + var cos = Math.Cos(Angle(i) * Math.PI / 180); + var sin = Math.Sin(Angle(i) * Math.PI / 180); + // Each label hangs off its spoke, away from the middle. + canvas.Text(LabelAt(i), ChartMath.Polar(_center, _radius + 10, Angle(i)), 12, muted, + cos > 0.3 ? ChartAlign.Start : cos < -0.3 ? ChartAlign.End : ChartAlign.Center, + sin > 0.3 ? ChartAlign.Start : sin < -0.3 ? ChartAlign.End : ChartAlign.Center); + } + + var hovered = HoveredIndex; + for (var s = 0; s < Series.Count; s++) + { + var color = SeriesColor(s); + var points = Enumerable.Range(0, count) + .Select(i => ChartMath.Polar(_center, _radius * Math.Clamp(ValueAt(Series[s], i) / max, 0, 1) * Progress, Angle(i))) + .ToList(); + canvas.Polygon(points, ChartColor.Fade(color, FillOpacity)); + canvas.Polyline(points, color, 2, closed: true); + if (ShowDots) + foreach (var point in points) canvas.Polygon(ChartMath.Circle(point, 3.5), color); + if (hovered < 0) continue; + canvas.Polygon(ChartMath.Circle(points[hovered], 6), ShellTheme.Get(ShellToken.Background)); + canvas.Polygon(ChartMath.Circle(points[hovered], 4.5), color); + } + } + + protected override int HitTest(Point point) + { + var count = CategoryCount; + var dx = point.X - _center.X; + var dy = point.Y - _center.Y; + if (count == 0 || Math.Sqrt(dx * dx + dy * dy) > _radius + 24) return -1; + // The nearest spoke, clockwise from twelve o'clock. + var angle = (Math.Atan2(dy, dx) * 180 / Math.PI + 90 + 360) % 360; + return (int)Math.Round(angle / (360.0 / count)) % count; + } +} diff --git a/examples/MAUI.Demo/Components/UI/RadialChart.cs b/examples/MAUI.Demo/Components/UI/RadialChart.cs new file mode 100644 index 0000000..7789c40 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/RadialChart.cs @@ -0,0 +1,120 @@ +using System.Globalization; + +namespace MAUI.Demo.Components.UI; + +// Radial chart — the first series' values as concentric rings on a muted track, each filling +// clockwise from twelve o'clock towards Maximum. Rings are named by Labels and colored from +// the theme's chart colors in order, the first outermost. +// +// +// +public partial class RadialChart : ChartBase +{ + // The value of a full ring; by default the largest value. + public static readonly BindableProperty MaximumProperty = + BindableProperty.Create(nameof(Maximum), typeof(double), typeof(RadialChart), double.NaN, + propertyChanged: (b, o, n) => ((RadialChart)b).Refresh()); + + // The hole in the middle, as a share of the radius. + public static readonly BindableProperty InnerRadiusProperty = + BindableProperty.Create(nameof(InnerRadius), typeof(double), typeof(RadialChart), 0.35, + propertyChanged: (b, o, n) => ((RadialChart)b).Refresh()); + + // Rounds the ends of each ring. + public static readonly BindableProperty IsRoundedProperty = + BindableProperty.Create(nameof(IsRounded), typeof(bool), typeof(RadialChart), true, + propertyChanged: (b, o, n) => ((RadialChart)b).Refresh()); + + // Large text in the middle. + public static readonly BindableProperty CenterValueProperty = + BindableProperty.Create(nameof(CenterValue), typeof(string), typeof(RadialChart), "", + propertyChanged: (b, o, n) => ((RadialChart)b).Refresh()); + + // Small muted text under CenterValue. + public static readonly BindableProperty CenterLabelProperty = + BindableProperty.Create(nameof(CenterLabel), typeof(string), typeof(RadialChart), "", + propertyChanged: (b, o, n) => ((RadialChart)b).Refresh()); + + public double Maximum + { + get => (double)GetValue(MaximumProperty); + set => SetValue(MaximumProperty, value); + } + + public double InnerRadius + { + get => (double)GetValue(InnerRadiusProperty); + set => SetValue(InnerRadiusProperty, value); + } + + public bool IsRounded + { + get => (bool)GetValue(IsRoundedProperty); + set => SetValue(IsRoundedProperty, value); + } + + public string CenterValue + { + get => (string)GetValue(CenterValueProperty); + set => SetValue(CenterValueProperty, value); + } + + public string CenterLabel + { + get => (string)GetValue(CenterLabelProperty); + set => SetValue(CenterLabelProperty, value); + } + + private Point _center; + private double _outer; + private double _hole; + private double _band; + + protected override bool HasData => Series.Count > 0 && Series[0].Values.Count > 0; + + protected override void DrawChart(IChartCanvas canvas, Rect area) + { + var values = Series[0].Values; + _center = new Point(area.Left + area.Width / 2, area.Top + area.Height / 2); + _outer = Math.Max(8, Math.Min(area.Width, area.Height) / 2 - 8); + _hole = _outer * Math.Clamp(InnerRadius, 0.05, 0.9); + _band = (_outer - _hole) / values.Count; + var thickness = _band - Math.Min(4, _band * 0.25); + var max = double.IsNaN(Maximum) ? values.Max() : Maximum; + if (max <= 0) max = 1; + var track = ShellTheme.Get(ShellToken.Muted); + + // One ring per value, the first outermost, each filling clockwise from twelve o'clock. + for (var i = 0; i < values.Count; i++) + { + var outer = _outer - i * _band; + var inner = outer - thickness; + canvas.Polygon(ChartMath.Sector(_center, inner, outer, 0, 360), track); + var sweep = Math.Clamp(values[i] / max, 0, 1) * 360 * Progress; + if (sweep <= 0) continue; + var grow = HoveredIndex == i ? 2 : 0; + var color = Palette(i); + canvas.Polygon(ChartMath.Sector(_center, inner - grow, outer + grow, -90, sweep), color); + if (!IsRounded || sweep >= 359) continue; + var middle = (inner + outer) / 2; + canvas.Polygon(ChartMath.Circle(ChartMath.Polar(_center, middle, -90), thickness / 2 + grow), color); + canvas.Polygon(ChartMath.Circle(ChartMath.Polar(_center, middle, -90 + sweep), thickness / 2 + grow), color); + } + DrawCenterText(canvas, _center, CenterValue, CenterLabel); + } + + protected override int HitTest(Point point) + { + var dx = point.X - _center.X; + var dy = point.Y - _center.Y; + var distance = Math.Sqrt(dx * dx + dy * dy); + if (_band <= 0 || distance < _hole || distance > _outer) return -1; + return Math.Clamp((int)Math.Floor((_outer - distance) / _band), 0, Series[0].Values.Count - 1); + } + + protected override List<(Color Color, string Name)> LegendEntries() => + Series[0].Values.Select((_, i) => (Palette(i), LabelAt(i))).ToList(); + + protected override (string Title, List<(Color Color, string Name, string Value)> Rows) Tooltip(int index) => + (string.Empty, new List<(Color Color, string Name, string Value)> { (Palette(index), LabelAt(index), Format(Series[0].Values[index])) }); +} diff --git a/examples/MAUI.Demo/Components/UI/Shell.cs b/examples/MAUI.Demo/Components/UI/Shell.cs index adb6fe0..dbc303c 100644 --- a/examples/MAUI.Demo/Components/UI/Shell.cs +++ b/examples/MAUI.Demo/Components/UI/Shell.cs @@ -23,7 +23,8 @@ public enum ShellToken Warning, WarningForeground, Info, InfoForeground, Border, Input, Ring, - Overlay + Overlay, + Chart1, Chart2, Chart3, Chart4, Chart5 } // Publishes the active palette as application resources ("ShellUIPrimary" for the Color, @@ -66,6 +67,11 @@ public static class ShellTheme [ShellToken.Input] = Color.FromArgb("#E4E4E4"), [ShellToken.Ring] = Color.FromArgb("#A3A3A3"), [ShellToken.Overlay] = Color.FromArgb("#80000000"), + [ShellToken.Chart1] = Color.FromArgb("#FFAE04"), + [ShellToken.Chart2] = Color.FromArgb("#2D62EF"), + [ShellToken.Chart3] = Color.FromArgb("#A4A4A4"), + [ShellToken.Chart4] = Color.FromArgb("#E4E4E4"), + [ShellToken.Chart5] = Color.FromArgb("#747474"), }; public static Dictionary Dark { get; } = new() @@ -96,6 +102,11 @@ public static class ShellTheme [ShellToken.Input] = Color.FromArgb("#333333"), [ShellToken.Ring] = Color.FromArgb("#737373"), [ShellToken.Overlay] = Color.FromArgb("#B3000000"), + [ShellToken.Chart1] = Color.FromArgb("#FFAE04"), + [ShellToken.Chart2] = Color.FromArgb("#2671F4"), + [ShellToken.Chart3] = Color.FromArgb("#747474"), + [ShellToken.Chart4] = Color.FromArgb("#525252"), + [ShellToken.Chart5] = Color.FromArgb("#E4E4E4"), }; // --radius: 0.5rem. Tailwind's rounded-sm / rounded-md / rounded-lg derive from it. diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml index 038613d..b30338f 100644 --- a/examples/MAUI.Demo/MainPage.xaml +++ b/examples/MAUI.Demo/MainPage.xaml @@ -63,6 +63,7 @@ + @@ -926,6 +927,106 @@ + + + +