diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/Assets.razor b/Schaad.Accounting.UI/Components/Pages/Charts/Assets.razor index d40a8bd..dabb4d9 100644 --- a/Schaad.Accounting.UI/Components/Pages/Charts/Assets.razor +++ b/Schaad.Accounting.UI/Components/Pages/Charts/Assets.razor @@ -1,13 +1,15 @@ -@page "/Assets" +@page "/Assets" @rendermode InteractiveServer -Ausgaben +Vermögen -@if (config is not null && layout is not null && data is not null) -{ - -} -else -{ - -} \ No newline at end of file + + @if (config is not null && layout is not null && data is not null) + { + + } + else + { + + } + diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/Assets.razor.cs b/Schaad.Accounting.UI/Components/Pages/Charts/Assets.razor.cs index 8b0747f..112eb91 100644 --- a/Schaad.Accounting.UI/Components/Pages/Charts/Assets.razor.cs +++ b/Schaad.Accounting.UI/Components/Pages/Charts/Assets.razor.cs @@ -1,6 +1,7 @@ -using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components; using Plotly.Blazor; using Plotly.Blazor.Traces; +using Schaad.Accounting.Datasets.Charts; using Schaad.Accounting.Interfaces; namespace Schaad.Accounting.UI.Components.Pages.Charts; @@ -11,45 +12,23 @@ public partial class Assets : ComponentBase private Config? config; private Plotly.Blazor.Layout? layout; private IList? data; - + [Inject] private IViewService viewService { get; set; } = null!; - + protected override async Task OnInitializedAsync() { - config = new Config - { - Responsive = true - }; - - layout = new Plotly.Blazor.Layout - { - }; + config = ChartTheme.BaseConfig(); + layout = ChartTheme.BaseLayout("Vermögen nach Konto"); var accounts = (await viewService.GetAccountViewListAsync()).Where(a => a.Class == ClassIds.Activa); - var values = new List(); - var labels = new List(); - var ids = new List(); - foreach (var account in accounts) - { - if (account.BalanceCHF > 0) - { - values.Add(account.BalanceCHF); - labels.Add(account.Name); - ids.Add(account.Id); - } - } + var totals = accounts + .Where(a => a.BalanceCHF > 0) + .Select(a => new CategoryTotal(a.Name, a.BalanceCHF)) + .ToList(); - data = new List - { - new Pie - { - Values = values, - Labels = labels, - Ids = ids - } - }; + data = new List { ChartTheme.BuildDonutTrace(ChartFormatting.FoldTail(totals)) }; await base.OnInitializedAsync(); } -} \ No newline at end of file +} diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor index c6079a4..c06fb9b 100644 --- a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor +++ b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor @@ -1,6 +1,8 @@ @if (config is not null && layout is not null && data is not null) { - + + + } else { diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor.cs b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor.cs index d8b6e8f..fce113e 100644 --- a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor.cs +++ b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor.cs @@ -20,25 +20,10 @@ public partial class BookingTextBreakdown : ComponentBase protected override async Task OnInitializedAsync() { - config = new Config - { - Responsive = true - }; - - layout = new Plotly.Blazor.Layout - { - Title = new Plotly.Blazor.LayoutLib.Title { Text = "Nach Buchungstext" } - }; + config = ChartTheme.BaseConfig(); + layout = ChartTheme.BaseLayout("Nach Buchungstext"); var totals = await chartService.GetBookingTextTotalsAsync(AccountId); - data = new List - { - new Pie - { - // Values are shown as their absolute magnitude - a pie slice can't represent a signed amount. - Values = totals.Select(t => (object)Math.Abs(t.Value)).ToList(), - Labels = totals.Select(t => (object)t.Name).ToList() - } - }; + data = new List { ChartTheme.BuildDonutTrace(ChartFormatting.FoldTail(totals)) }; } } diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor index 659020f..dc1a94f 100644 --- a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor +++ b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor @@ -1,6 +1,8 @@ @if (config is not null && layout is not null) { - + + + } else { diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor.cs b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor.cs index 732fb71..611c7fe 100644 --- a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor.cs +++ b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor.cs @@ -21,36 +21,34 @@ public partial class BookingTextOverTime : ComponentBase protected override async Task OnInitializedAsync() { - config = new Config - { - Responsive = true - }; + config = ChartTheme.BaseConfig(); - layout = new Plotly.Blazor.Layout + layout = ChartTheme.BaseLayout("Nach Buchungstext im Verlauf"); + layout.BarMode = BarModeEnum.Stack; + layout.BarCornerRadius = 4; + layout.HoverMode = HoverModeEnum.XUnified; + layout.XAxis = new List { - Title = new Title { Text = "Nach Buchungstext im Verlauf" }, - BarMode = BarModeEnum.Stack, - XAxis = new List + new() { - new() - { - Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Date - } + Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Category, + GridColor = ChartTheme.GridColor, + LineColor = ChartTheme.GridColor + } + }; + layout.YAxis = new List + { + new() + { + Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" }, + GridColor = ChartTheme.GridColor, + ZeroLineColor = ChartTheme.GridColor, + SeparateThousands = true } }; - var newData = new List(); var dataSeries = await chartService.GetBookingTextExpensesPerMonthAsync(AccountId); - foreach (var dataSerie in dataSeries) - { - newData.Add(new Bar - { - Name = dataSerie.Name, - X = dataSerie.X.Select(d => (object)d).ToList(), - Y = dataSerie.Y.Select(d => (object)d).ToList(), - }); - } - data = newData; + data = ChartTheme.BuildStackedBarTraces(ChartFormatting.FoldTail(dataSeries)); await base.OnInitializedAsync(); } diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/ChartFormatting.cs b/Schaad.Accounting.UI/Components/Pages/Charts/ChartFormatting.cs new file mode 100644 index 0000000..18c8605 --- /dev/null +++ b/Schaad.Accounting.UI/Components/Pages/Charts/ChartFormatting.cs @@ -0,0 +1,72 @@ +using Schaad.Accounting; +using Schaad.Accounting.Datasets.Charts; + +namespace Schaad.Accounting.UI.Components.Pages.Charts; + +/// +/// Swiss (de-CH) formatting for chart labels/tooltips, plus the top-N + "Andere" +/// folding used to keep pies and stacked series legible. Plotly's own d3-format +/// tokens can't produce the apostrophe thousands separator, so values are +/// pre-formatted here in C# and passed to Plotly as literal text. +/// +public static class ChartFormatting +{ + private static readonly string[] MonthAbbreviations = + [ + "Jan", "Feb", "Mrz", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez" + ]; + + public static string MonthLabel(DateOnly date) => $"{MonthAbbreviations[date.Month - 1]} {date.Year % 100:00}"; + + public static string Chf(decimal value) => $"{value.ToFormattedString()} CHF"; + + /// + /// Sorts by magnitude descending and, past , + /// collapses the remainder into a single "Andere" entry so a pie/legend never + /// has to carry more slices than the categorical palette can distinguish. + /// + public static IReadOnlyList FoldTail(IReadOnlyList items) + { + var sorted = items.OrderByDescending(i => Math.Abs(i.Value)).ToList(); + if (sorted.Count <= ChartTheme.MaxCategories) + { + return sorted; + } + + var head = sorted.Take(ChartTheme.MaxCategories - 1).ToList(); + var tailTotal = sorted.Skip(ChartTheme.MaxCategories - 1).Sum(i => i.Value); + head.Add(new CategoryTotal(ChartTheme.OtherLabel, tailTotal)); + return head; + } + + /// + /// Same idea as for a set of + /// monthly series (e.g. a stacked bar chart): series are ranked by total magnitude + /// across all months, and everything past the cap is summed month-by-month into + /// a single "Andere" series. + /// + public static IReadOnlyList FoldTail(IReadOnlyList series) + { + if (series.Count <= ChartTheme.MaxCategories) + { + return series; + } + + var ranked = series.OrderByDescending(s => s.Y.Sum(Math.Abs)).ToList(); + var head = ranked.Take(ChartTheme.MaxCategories - 1).ToList(); + var tail = ranked.Skip(ChartTheme.MaxCategories - 1).ToList(); + + var x = tail[0].X; + var otherY = new decimal[x.Count]; + foreach (var serie in tail) + { + for (var i = 0; i < x.Count; i++) + { + otherY[i] += serie.Y[i]; + } + } + + head.Add(new DataSerie(ChartTheme.OtherLabel, ChartTheme.OtherLabel, x, otherY)); + return head; + } +} diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/ChartTheme.cs b/Schaad.Accounting.UI/Components/Pages/Charts/ChartTheme.cs new file mode 100644 index 0000000..22f3ad0 --- /dev/null +++ b/Schaad.Accounting.UI/Components/Pages/Charts/ChartTheme.cs @@ -0,0 +1,144 @@ +using Plotly.Blazor; +using Plotly.Blazor.LayoutLib; +using Plotly.Blazor.Traces; +using Schaad.Accounting.Datasets.Charts; + +namespace Schaad.Accounting.UI.Components.Pages.Charts; + +/// +/// Shared look for every chart: a fixed-order, colour-vision-safe categorical +/// palette plus Fluent-matching chrome. The palette is the validated default +/// from the project's data-viz guidelines (scripts/validate_palette.js) - do +/// not add or reorder hues without re-running the validator. +/// +public static class ChartTheme +{ + public static readonly string[] Categorical = + [ + "#2a78d6", // blue + "#eb6834", // orange + "#1baf7a", // aqua + "#eda100", // yellow + "#e87ba4", // magenta + "#008300", // green + "#4a3aa7", // violet + "#e34948" // red + ]; + + /// Beyond this many categories, fold the tail into a single "Andere" bucket. + public const int MaxCategories = 7; + + public const string OtherLabel = "Andere"; + + // Muted ink, not a series hue - "Andere" must never look like a real category. + public const string OtherColor = "#898781"; + + public const string GridColor = "#e1e0d9"; + public const string PrimaryInk = "#0b0b0b"; + public const string MutedInk = "#52514e"; + public const string PlotSurface = "#fcfcfb"; + + public const string FontFamily = "'Helvetica Neue', Helvetica, Arial, sans-serif"; + + public static string ColorAt(int index) => index < Categorical.Length ? Categorical[index] : OtherColor; + + public static Config BaseConfig() => new() + { + Responsive = true, + DisplayLogo = false + }; + + public static Plotly.Blazor.Layout BaseLayout(string? title = null) + { + var layout = new Plotly.Blazor.Layout + { + Font = new Font { Family = FontFamily, Color = PrimaryInk, Size = 12 }, + PaperBgColor = PlotSurface, + PlotBgColor = PlotSurface, + Margin = new Margin { L = 48, R = 24, T = string.IsNullOrEmpty(title) ? 16 : 48, B = 40, Pad = 4 }, + Legend = new List + { + new() + { + Font = new() { Family = FontFamily, Color = MutedInk, Size = 12 }, + BgColor = "rgba(0,0,0,0)" + } + }, + HoverLabel = new() + { + BgColor = "#ffffff", + BorderColor = GridColor, + Font = new() { Family = FontFamily, Color = PrimaryInk, Size = 12 } + } + }; + + if (!string.IsNullOrEmpty(title)) + { + layout.Title = new Title { Text = title, Font = new() { Family = FontFamily, Color = PrimaryInk, Size = 15 } }; + } + + return layout; + } + + /// + /// A donut trace for a set of already-folded () + /// categories: fixed-order palette, CH-formatted hover text, and direct labels + /// outside the slice (values are magnitudes - a slice can't represent a sign). + /// + public static Pie BuildDonutTrace(IReadOnlyList items) + { + var labels = new List(); + var values = new List(); + var text = new List(); + var colors = new List(); + + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + labels.Add(item.Name); + values.Add(Math.Abs(item.Value)); + text.Add(ChartFormatting.Chf(item.Value)); + colors.Add(item.Name == OtherLabel ? OtherColor : ColorAt(i)); + } + + return new Pie + { + Labels = labels, + Values = values, + Text = text, + Sort = false, + Hole = 0.55m, + Marker = new() { Colors = colors, Line = new() { Color = PlotSurface, Width = 2 } }, + TextInfo = Plotly.Blazor.Traces.PieLib.TextInfoFlag.Label, + TextPosition = Plotly.Blazor.Traces.PieLib.TextPositionEnum.Outside, + OutsideTextFont = new() { Family = FontFamily, Color = MutedInk, Size = 12 }, + HoverTemplate = "%{label}
%{text}" + }; + } + + /// + /// One stacked-bar trace per already-folded () + /// series: fixed-order palette, a hairline surface gap between stacked segments, + /// CH month labels on the x-axis, and CH-formatted hover text. + /// + public static List BuildStackedBarTraces(IReadOnlyList series) + { + var traces = new List(); + for (var i = 0; i < series.Count; i++) + { + var serie = series[i]; + var color = serie.Name == OtherLabel ? OtherColor : ColorAt(i); + + traces.Add(new Bar + { + Name = serie.Name, + X = serie.X.Select(d => (object)ChartFormatting.MonthLabel(d)).ToList(), + Y = serie.Y.Select(v => (object)v).ToList(), + TextArray = serie.Y.Select(ChartFormatting.Chf).ToList(), + Marker = new() { Color = color, Line = new() { Color = PlotSurface, Width = 2 } }, + HoverTemplate = "%{fullData.name}: %{text}" + }); + } + return traces; + } +} diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/Spendings.razor b/Schaad.Accounting.UI/Components/Pages/Charts/Spendings.razor index 2a05645..63e344e 100644 --- a/Schaad.Accounting.UI/Components/Pages/Charts/Spendings.razor +++ b/Schaad.Accounting.UI/Components/Pages/Charts/Spendings.razor @@ -1,13 +1,15 @@ -@page "/spendings" +@page "/spendings" @rendermode InteractiveServer Ausgaben -@if (config is not null && layout is not null && data is not null) -{ - -} -else -{ - -} \ No newline at end of file + + @if (config is not null && layout is not null && data is not null) + { + + } + else + { + + } + diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/Spendings.razor.cs b/Schaad.Accounting.UI/Components/Pages/Charts/Spendings.razor.cs index 6ab31f6..7be2446 100644 --- a/Schaad.Accounting.UI/Components/Pages/Charts/Spendings.razor.cs +++ b/Schaad.Accounting.UI/Components/Pages/Charts/Spendings.razor.cs @@ -1,6 +1,7 @@ -using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components; using Plotly.Blazor; using Plotly.Blazor.Traces; +using Schaad.Accounting.Datasets.Charts; using Schaad.Accounting.Interfaces; namespace Schaad.Accounting.UI.Components.Pages.Charts; @@ -11,42 +12,24 @@ public partial class Spendings : ComponentBase private Config? config; private Plotly.Blazor.Layout? layout; private IList? data; - + [Inject] private IViewService viewService { get; set; } = null!; - + protected override async Task OnInitializedAsync() { - config = new Config - { - Responsive = true - }; - - layout = new Plotly.Blazor.Layout - { - }; + config = ChartTheme.BaseConfig(); + layout = ChartTheme.BaseLayout("Ausgaben nach Kategorie"); var accounts = (await viewService.GetAccountViewListAsync()).Where(a => a.Class == ClassIds.Expenses); - var values = new List(); - var labels = new List(); - foreach (var grp in accounts.GroupBy(a => a.SubClass).Select(a => new {Key = a.Key, List = a.ToList()})) - { - if (grp.List.Sum(g => g.Balance) != 0) - { - values.Add(grp.List.Sum(g => g.Balance)); - labels.Add(grp.List.First().SubClassName); - } - } + var totals = accounts + .GroupBy(a => a.SubClass) + .Select(g => new CategoryTotal(g.First().SubClassName, g.Sum(a => a.Balance))) + .Where(t => t.Value != 0) + .ToList(); - data = new List - { - new Pie - { - Values =values, - Labels = labels - } - }; + data = new List { ChartTheme.BuildDonutTrace(ChartFormatting.FoldTail(totals)) }; await base.OnInitializedAsync(); } -} \ No newline at end of file +} diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/SpendingsOverTime.razor b/Schaad.Accounting.UI/Components/Pages/Charts/SpendingsOverTime.razor index 4039311..1f11b61 100644 --- a/Schaad.Accounting.UI/Components/Pages/Charts/SpendingsOverTime.razor +++ b/Schaad.Accounting.UI/Components/Pages/Charts/SpendingsOverTime.razor @@ -1,13 +1,15 @@ -@page "/SpendingsOverTime" +@page "/SpendingsOverTime" @rendermode InteractiveServer Ausgaben im Verlauf -@if (config is not null && layout is not null) -{ - -} -else -{ - -} \ No newline at end of file + + @if (config is not null && layout is not null) + { + + } + else + { + + } + diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/SpendingsOverTime.razor.cs b/Schaad.Accounting.UI/Components/Pages/Charts/SpendingsOverTime.razor.cs index abd7497..7f1f86f 100644 --- a/Schaad.Accounting.UI/Components/Pages/Charts/SpendingsOverTime.razor.cs +++ b/Schaad.Accounting.UI/Components/Pages/Charts/SpendingsOverTime.razor.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components; using Plotly.Blazor; using Plotly.Blazor.LayoutLib; using Plotly.Blazor.Traces; @@ -12,49 +12,41 @@ public partial class SpendingsOverTime : ComponentBase private Config? config; private Plotly.Blazor.Layout? layout; private IList data = new List(); - + [Inject] private IChartService chartService { get; set; } = null!; - + protected override async Task OnInitializedAsync() { - config = new Config - { - Responsive = true - }; + config = ChartTheme.BaseConfig(); - layout = new Plotly.Blazor.Layout + layout = ChartTheme.BaseLayout("Ausgaben im Verlauf"); + layout.BarMode = BarModeEnum.Stack; + layout.BarCornerRadius = 4; + layout.HoverMode = HoverModeEnum.XUnified; + layout.XAxis = new List { - Title = new Title { Text = "Ausgaben im Verlauf" }, - BarMode = BarModeEnum.Stack, - YAxis = new List + new() { - new() - { - Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" } - } - }, - XAxis = new List - { - new() - { - Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Date - } + Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Category, + GridColor = ChartTheme.GridColor, + LineColor = ChartTheme.GridColor + } + }; + layout.YAxis = new List + { + new() + { + Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" }, + GridColor = ChartTheme.GridColor, + ZeroLineColor = ChartTheme.GridColor, + SeparateThousands = true } }; - var dataSeries = await chartService.GetExpensesPerMonthAsync(); - foreach (var dataSerie in dataSeries) - { - data.Add(new Bar - { - Name = dataSerie.Name, - X = dataSerie.X.Select(d => (object)d).ToList(), - Y = dataSerie.Y.Select(d => (object)d).ToList(), - }); - }; + data = ChartTheme.BuildStackedBarTraces(ChartFormatting.FoldTail(dataSeries)); await base.OnInitializedAsync(); } -} \ No newline at end of file +}