UI Chart improvements

This commit is contained in:
Claudio Schaad 2026-08-08 15:04:38 +02:00
parent 4cc0888627
commit 0fdecc9e67
12 changed files with 330 additions and 167 deletions

View file

@ -1,13 +1,15 @@
@page "/Assets"
@page "/Assets"
@rendermode InteractiveServer
<PageTitle>Ausgaben</PageTitle>
<PageTitle>Vermögen</PageTitle>
@if (config is not null && layout is not null && data is not null)
{
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}
else
{
<Loading/>
}
<FluentCard Style="padding: 16px; max-width: 900px;">
@if (config is not null && layout is not null && data is not null)
{
<PlotlyChart style="height: 560px; min-height: 420px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}
else
{
<Loading/>
}
</FluentCard>

View file

@ -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;
@ -17,38 +18,16 @@ public partial class Assets : ComponentBase
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<object>();
var labels = new List<object>();
var ids = new List<object>();
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<ITrace>
{
new Pie
{
Values = values,
Labels = labels,
Ids = ids
}
};
data = new List<ITrace> { ChartTheme.BuildDonutTrace(ChartFormatting.FoldTail(totals)) };
await base.OnInitializedAsync();
}

View file

@ -1,6 +1,8 @@
@if (config is not null && layout is not null && data is not null)
{
<PlotlyChart style="height: 400px; min-height: 300px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
<FluentCard Style="padding: 16px;">
<PlotlyChart style="height: 400px; min-height: 300px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
</FluentCard>
}
else
{

View file

@ -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<ITrace>
{
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<ITrace> { ChartTheme.BuildDonutTrace(ChartFormatting.FoldTail(totals)) };
}
}

View file

@ -1,6 +1,8 @@
@if (config is not null && layout is not null)
{
<PlotlyChart style="height: 400px; min-height: 300px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
<FluentCard Style="padding: 16px;">
<PlotlyChart style="height: 400px; min-height: 300px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
</FluentCard>
}
else
{

View file

@ -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<XAxis>
{
Title = new Title { Text = "Nach Buchungstext im Verlauf" },
BarMode = BarModeEnum.Stack,
XAxis = new List<XAxis>
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<YAxis>
{
new()
{
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" },
GridColor = ChartTheme.GridColor,
ZeroLineColor = ChartTheme.GridColor,
SeparateThousands = true
}
};
var newData = new List<ITrace>();
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();
}

View file

@ -0,0 +1,72 @@
using Schaad.Accounting;
using Schaad.Accounting.Datasets.Charts;
namespace Schaad.Accounting.UI.Components.Pages.Charts;
/// <summary>
/// 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.
/// </summary>
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";
/// <summary>
/// Sorts by magnitude descending and, past <see cref="ChartTheme.MaxCategories"/>,
/// collapses the remainder into a single "Andere" entry so a pie/legend never
/// has to carry more slices than the categorical palette can distinguish.
/// </summary>
public static IReadOnlyList<CategoryTotal> FoldTail(IReadOnlyList<CategoryTotal> 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;
}
/// <summary>
/// Same idea as <see cref="FoldTail(IReadOnlyList{CategoryTotal})"/> 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.
/// </summary>
public static IReadOnlyList<DataSerie> FoldTail(IReadOnlyList<DataSerie> 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;
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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
];
/// <summary>Beyond this many categories, fold the tail into a single "Andere" bucket.</summary>
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<Legend>
{
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;
}
/// <summary>
/// A donut trace for a set of already-folded (<see cref="ChartFormatting.FoldTail(IReadOnlyList{CategoryTotal})"/>)
/// categories: fixed-order palette, CH-formatted hover text, and direct labels
/// outside the slice (values are magnitudes - a slice can't represent a sign).
/// </summary>
public static Pie BuildDonutTrace(IReadOnlyList<CategoryTotal> items)
{
var labels = new List<object>();
var values = new List<object>();
var text = new List<object>();
var colors = new List<object>();
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 = "<b>%{label}</b><br>%{text}<extra></extra>"
};
}
/// <summary>
/// One stacked-bar trace per already-folded (<see cref="ChartFormatting.FoldTail(IReadOnlyList{DataSerie})"/>)
/// series: fixed-order palette, a hairline surface gap between stacked segments,
/// CH month labels on the x-axis, and CH-formatted hover text.
/// </summary>
public static List<ITrace> BuildStackedBarTraces(IReadOnlyList<DataSerie> series)
{
var traces = new List<ITrace>();
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}<extra></extra>"
});
}
return traces;
}
}

View file

@ -1,13 +1,15 @@
@page "/spendings"
@page "/spendings"
@rendermode InteractiveServer
<PageTitle>Ausgaben</PageTitle>
@if (config is not null && layout is not null && data is not null)
{
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}
else
{
<Loading/>
}
<FluentCard Style="padding: 16px; max-width: 900px;">
@if (config is not null && layout is not null && data is not null)
{
<PlotlyChart style="height: 560px; min-height: 420px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}
else
{
<Loading/>
}
</FluentCard>

View file

@ -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;
@ -17,35 +18,17 @@ public partial class Spendings : ComponentBase
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<object>();
var labels = new List<object>();
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<ITrace>
{
new Pie
{
Values =values,
Labels = labels
}
};
data = new List<ITrace> { ChartTheme.BuildDonutTrace(ChartFormatting.FoldTail(totals)) };
await base.OnInitializedAsync();
}

View file

@ -1,13 +1,15 @@
@page "/SpendingsOverTime"
@page "/SpendingsOverTime"
@rendermode InteractiveServer
<PageTitle>Ausgaben im Verlauf</PageTitle>
@if (config is not null && layout is not null)
{
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}
else
{
<Loading/>
}
<FluentCard Style="padding: 16px;">
@if (config is not null && layout is not null)
{
<PlotlyChart style="height: 560px; min-height: 420px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}
else
{
<Loading/>
}
</FluentCard>

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components;
using Plotly.Blazor;
using Plotly.Blazor.LayoutLib;
using Plotly.Blazor.Traces;
@ -18,42 +18,34 @@ public partial class SpendingsOverTime : ComponentBase
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<XAxis>
{
Title = new Title { Text = "Ausgaben im Verlauf" },
BarMode = BarModeEnum.Stack,
YAxis = new List<YAxis>
new()
{
new()
{
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" }
}
},
XAxis = new List<XAxis>
Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Category,
GridColor = ChartTheme.GridColor,
LineColor = ChartTheme.GridColor
}
};
layout.YAxis = new List<YAxis>
{
new()
{
new()
{
Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Date
}
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();
}