diff --git a/Radzen.Blazor.Tests/PieDonutGradientTests.cs b/Radzen.Blazor.Tests/PieDonutGradientTests.cs new file mode 100644 index 000000000..20417cd5a --- /dev/null +++ b/Radzen.Blazor.Tests/PieDonutGradientTests.cs @@ -0,0 +1,211 @@ +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Bunit; +using Xunit; +using static Radzen.Blazor.Tests.ChartTestHelper; + +namespace Radzen.Blazor.Tests +{ + public class PieDonutGradientTests + { + static int RadialGradients(string markup) => Regex.Matches(markup, " RenderPie(TestContext ctx, FillMode fillMode = FillMode.Gradient, string[]? fills = null) + { + var chart = ctx.RenderComponent(p => + p.AddChildContent>(s => + { + s.Add(x => x.CategoryProperty, nameof(DataItem.Category)); + s.Add(x => x.ValueProperty, nameof(DataItem.Value)); + s.Add(x => x.FillMode, fillMode); + if (fills != null) + { + s.Add(x => x.Fills, fills); + } + s.Add(x => x.Data, SampleData); + })); + + await chart.InvokeAsync(() => chart.Instance.Resize(400, 300)); + + return chart.Markup; + } + + static async Task RenderDonut(TestContext ctx, FillMode fillMode = FillMode.Gradient, string[]? fills = null) + { + var chart = ctx.RenderComponent(p => + p.AddChildContent>(s => + { + s.Add(x => x.CategoryProperty, nameof(DataItem.Category)); + s.Add(x => x.ValueProperty, nameof(DataItem.Value)); + s.Add(x => x.FillMode, fillMode); + if (fills != null) + { + s.Add(x => x.Fills, fills); + } + s.Add(x => x.Data, SampleData); + })); + + await chart.InvokeAsync(() => chart.Instance.Resize(400, 300)); + + return chart.Markup; + } + + [Fact] + public async Task Pie_SolidByDefault_RendersNoGradient() + { + using var ctx = CreateChartContext(); + + // No FillMode set -> the library default (Solid) must not emit a gradient. + var chart = ctx.RenderComponent(p => + p.AddChildContent>(s => + { + s.Add(x => x.CategoryProperty, nameof(DataItem.Category)); + s.Add(x => x.ValueProperty, nameof(DataItem.Value)); + s.Add(x => x.Data, SampleData); + })); + + await chart.InvokeAsync(() => chart.Instance.Resize(400, 300)); + + Assert.Equal(0, RadialGradients(chart.Markup)); + Assert.DoesNotContain("url(#rzPieGradient", chart.Markup); + } + + [Fact] + public async Task Pie_Gradient_RendersRadialPerSlice() + { + using var ctx = CreateChartContext(); + + var markup = await RenderPie(ctx, fills: new[] { "#FF0000", "#00FF00", "#0000FF" }); + + // Pie uses a 2-stop radial per slice (the inverse of the donut: full color at the center, + // faded toward the rim), referenced from its slice path. + Assert.Equal(3, RadialGradients(markup)); + Assert.Contains("url(#rzPieGradient", markup); + Assert.Matches("]*gradientUnits=\"userSpaceOnUse\"", markup); + } + + [Fact] + public async Task Pie_Gradient_HasThreeOpacityStops() + { + using var ctx = CreateChartContext(); + + var markup = await RenderPie(ctx, fills: new[] { "#FF0000", "#00FF00", "#0000FF" }); + + // 3 slices x 3 stops each. + Assert.Equal(9, Regex.Matches(markup, "]*stop-opacity: 1", markup); + Assert.Matches("offset=\"0.9\"[^>]*stop-opacity: 0.62", markup); + Assert.Matches("offset=\"1\"[^>]*stop-opacity: 0.7", markup); + // Opacity, not a white mix - so the gradient adapts to the theme background. + Assert.DoesNotContain("color-mix", markup); + } + + [Fact] + public async Task Pie_Gradient_StopShapeIsParameterized() + { + using var ctx = CreateChartContext(); + + var chart = ctx.RenderComponent(p => + p.AddChildContent>(s => + { + s.Add(x => x.CategoryProperty, nameof(DataItem.Category)); + s.Add(x => x.ValueProperty, nameof(DataItem.Value)); + s.Add(x => x.FillMode, FillMode.Gradient); + s.Add(x => x.GradientInnerOffset, 0.25); + s.Add(x => x.GradientMidOffset, 0.7); + s.Add(x => x.GradientRimOpacity, 0.8); + s.Add(x => x.Fills, new[] { "#FF0000", "#00FF00", "#0000FF" }); + s.Add(x => x.Data, SampleData); + })); + + await chart.InvokeAsync(() => chart.Instance.Resize(400, 300)); + + Assert.Contains("offset=\"0.25\"", chart.Markup); + Assert.Contains("offset=\"0.7\"", chart.Markup); + Assert.Matches("offset=\"1\"[^>]*stop-opacity: 0.8", chart.Markup); + } + + [Fact] + public async Task Donut_Gradient_HasThreeOpacityStops() + { + using var ctx = CreateChartContext(); + + var markup = await RenderDonut(ctx, fills: new[] { "#FF0000", "#00FF00", "#0000FF" }); + + // The donut uses the same 3-stop radial as the pie (full color out to 0.4, faded middle at 0.9, rim at 1). + Assert.Equal(9, Regex.Matches(markup, "]*stop-opacity: 1", markup); + Assert.Matches("offset=\"0.9\"[^>]*stop-opacity: 0.62", markup); + Assert.Matches("offset=\"1\"[^>]*stop-opacity: 0.7", markup); + Assert.DoesNotContain("color-mix", markup); + } + + [Fact] + public async Task Pie_Gradient_ExplicitFills_StopColorIsSliceColor() + { + using var ctx = CreateChartContext(); + + var markup = await RenderPie(ctx, fills: new[] { "#FF0000", "#00FF00", "#0000FF" }); + + Assert.Contains("stop-color: #FF0000", markup); + Assert.Contains("stop-color: #00FF00", markup); + Assert.Contains("stop-color: #0000FF", markup); + } + + [Fact] + public async Task Pie_Gradient_NullFills_UsesSeriesColorVariable() + { + using var ctx = CreateChartContext(); + + // Without Fills, each slice's color comes from its .rz-series-item-N CSS scope, so the + // gradient stop must reference the series-color variable rather than an empty color. + var markup = await RenderPie(ctx); + + Assert.True(RadialGradients(markup) >= 1); + Assert.Contains("stop-color: var(--rz-series-color)", markup); + Assert.DoesNotContain("stop-color: ;", markup); + } + + [Fact] + public async Task Pie_Solid_RendersNoGradient() + { + using var ctx = CreateChartContext(); + + var markup = await RenderPie(ctx, fillMode: FillMode.Solid, fills: new[] { "#FF0000", "#00FF00", "#0000FF" }); + + Assert.Equal(0, RadialGradients(markup)); + Assert.DoesNotContain("url(#rzPieGradient", markup); + } + + [Fact] + public async Task Donut_Gradient_RendersRadialPerSlice() + { + using var ctx = CreateChartContext(); + + var markup = await RenderDonut(ctx, fills: new[] { "#FF0000", "#00FF00", "#0000FF" }); + + Assert.Equal(3, RadialGradients(markup)); + Assert.Contains("url(#rzPieGradient", markup); + Assert.Matches("]*gradientUnits=\"userSpaceOnUse\"", markup); + } + + [Fact] + public async Task Donut_SolidByDefault_RendersNoGradient() + { + using var ctx = CreateChartContext(); + + var chart = ctx.RenderComponent(p => + p.AddChildContent>(s => + { + s.Add(x => x.CategoryProperty, nameof(DataItem.Category)); + s.Add(x => x.ValueProperty, nameof(DataItem.Value)); + s.Add(x => x.Data, SampleData); + })); + + await chart.InvokeAsync(() => chart.Instance.Resize(400, 300)); + + Assert.Equal(0, RadialGradients(chart.Markup)); + } + } +} diff --git a/Radzen.Blazor/FillMode.cs b/Radzen.Blazor/FillMode.cs index 559386c95..1cb935bc3 100644 --- a/Radzen.Blazor/FillMode.cs +++ b/Radzen.Blazor/FillMode.cs @@ -2,7 +2,7 @@ { /// /// Specifies how a series shape is filled. Used by the area, column and bar series families - /// (including their stacked, full-stacked, range and bullet variants). + /// (including their stacked, full-stacked, range and bullet variants) and by the pie and donut series. /// public enum FillMode { diff --git a/Radzen.Blazor/RadzenDonutSeries.razor b/Radzen.Blazor/RadzenDonutSeries.razor index 64a02a29e..4ddd46e8f 100644 --- a/Radzen.Blazor/RadzenDonutSeries.razor +++ b/Radzen.Blazor/RadzenDonutSeries.razor @@ -32,7 +32,9 @@ public override RenderFragment Render(ScaleBase categoryScale, ScaleBase valueScale) { var chart = RequireChart(); - var className = $"rz-donut-series rz-series-{chart.Series.IndexOf(this)}"; + var seriesIndex = chart.Series.IndexOf(this); + var className = $"rz-donut-series rz-series-{seriesIndex}"; + var gradient = FillMode == FillMode.Gradient; var x = CenterX; var y = CenterY; var radius = CurrentRadius; @@ -64,14 +66,21 @@ var arcClassName = $"rz-series-item-{index}"; var fill = PickColor(index, Fills); var stroke = PickColor(index, Strokes); + var gradId = $"rzPieGradient{chart.UniqueID}-{seriesIndex}-{index}"; + var fillRef = gradient ? $"url(#{gradId})" : (FillMode == FillMode.None ? "none" : fill); + var gradientColor = fill ?? "var(--rz-series-color)"; var explodeStyle = ExplodeOffset > 0 ? $"--rz-explode-x: {(ExplodeOffset * Math.Cos(DegToRad(midAngle))).ToInvariantString()}px; --rz-explode-y: {(-ExplodeOffset * Math.Sin(DegToRad(midAngle))).ToInvariantString()}px" : ""; + @if (gradient) + { + + } @if (ExplodeOffset > 0) { } - + } } diff --git a/Radzen.Blazor/RadzenPieSeries.razor b/Radzen.Blazor/RadzenPieSeries.razor index 7fdb6e5ac..bf2c92acd 100644 --- a/Radzen.Blazor/RadzenPieSeries.razor +++ b/Radzen.Blazor/RadzenPieSeries.razor @@ -12,7 +12,9 @@ public override RenderFragment Render(ScaleBase categoryScale, ScaleBase valueScale) { var chart = RequireChart(); - var className = $"rz-pie-series rz-series-{chart.Series.IndexOf(this)}"; + var seriesIndex = chart.Series.IndexOf(this); + var className = $"rz-pie-series rz-series-{seriesIndex}"; + var gradient = FillMode == FillMode.Gradient; var x = CenterX; var y = CenterY; var radius = CurrentRadius; @@ -43,16 +45,23 @@ var arcClassName = $"rz-series-item-{index}"; var fill = PickColor(index, Fills); var stroke = PickColor(index, Strokes); + var gradId = $"rzPieGradient{chart.UniqueID}-{seriesIndex}-{index}"; + var fillRef = gradient ? $"url(#{gradId})" : (FillMode == FillMode.None ? "none" : fill); + var gradientColor = fill ?? "var(--rz-series-color)"; var explodeStyle = ExplodeOffset > 0 ? $"--rz-explode-x: {(ExplodeOffset * Math.Cos(DegToRad(midAngle))).ToInvariantString()}px; --rz-explode-y: {(-ExplodeOffset * Math.Sin(DegToRad(midAngle))).ToInvariantString()}px" : ""; @if (sweepAngle > 0) { + @if (gradient) + { + + } @if (ExplodeOffset > 0) { } - + } } diff --git a/Radzen.Blazor/RadzenPieSeries.razor.cs b/Radzen.Blazor/RadzenPieSeries.razor.cs index bb8722485..0d600bdab 100644 --- a/Radzen.Blazor/RadzenPieSeries.razor.cs +++ b/Radzen.Blazor/RadzenPieSeries.razor.cs @@ -105,6 +105,51 @@ namespace Radzen.Blazor [Parameter] public double StrokeWidth { get; set; } + /// + /// Specifies how the segments are filled. Set to by default. + /// Use for a radial fill that holds the full segment color through the inner part of the radius and eases to a faded outer area. + /// The faded part lets the chart background show through, so it is lighter on light themes and darker on dark themes. Use to render only the outline. + /// + /// The fill mode. Default is . + [Parameter] + public FillMode FillMode { get; set; } = FillMode.Solid; + + /// + /// Specifies the opacity of the faded middle stop of the gradient. + /// Below 1 it lets the chart background show through, so it eases lighter on light themes and darker on dark themes. Used when is . + /// + /// The gradient start opacity. Default is 0.62. + [Parameter] + public double GradientStartOpacity { get; set; } = 0.62; + + /// + /// Specifies the opacity of the full-color part of the gradient - the center of a pie, or the outer rim of a donut ring. Used when is . + /// + /// The gradient end opacity. Default is 1. + [Parameter] + public double GradientEndOpacity { get; set; } = 1.0; + + /// + /// Specifies the radius fraction (0-1) at which the gradient holds the full color before it starts to fade. Used when is . + /// + /// The gradient inner offset. Default is 0.4. + [Parameter] + public double GradientInnerOffset { get; set; } = 0.4; + + /// + /// Specifies the radius fraction (0-1) of the gradient's faded middle stop. Used when is . + /// + /// The gradient middle offset. Default is 0.9. + [Parameter] + public double GradientMidOffset { get; set; } = 0.9; + + /// + /// Specifies the opacity at the very rim of the gradient. Used when is . + /// + /// The gradient rim opacity. Default is 0.7. + [Parameter] + public double GradientRimOpacity { get; set; } = 0.7; + /// /// Gets or sets a value indicating whether hovering or clicking a legend item displays the tooltip for the corresponding pie/donut segment. /// This is useful when small slices are difficult to hover over directly on the chart. diff --git a/Radzen.Blazor/Rendering/RadialGradientDef.razor b/Radzen.Blazor/Rendering/RadialGradientDef.razor new file mode 100644 index 000000000..46b47d10c --- /dev/null +++ b/Radzen.Blazor/Rendering/RadialGradientDef.razor @@ -0,0 +1,74 @@ +@using Radzen.Blazor.Rendering + + + + @if (MidOffset.HasValue) + { + + } + + + +@code { + /// + /// The id of the gradient, referenced by the shape via fill: url(#id). + /// + [Parameter] + public string Id { get; set; } = string.Empty; + + /// + /// The gradient color, used by both stops (only the opacity varies). May be a literal color or a + /// CSS variable such as var(--rz-series-color), resolved against the element this renders inside. + /// + [Parameter] + public string Color { get; set; } = string.Empty; + + /// + /// The x coordinate of the gradient center, in user space (the chart SVG coordinate system). + /// + [Parameter] + public double Cx { get; set; } + + /// + /// The y coordinate of the gradient center, in user space. + /// + [Parameter] + public double Cy { get; set; } + + /// + /// The radius of the gradient, in user space. + /// + [Parameter] + public double R { get; set; } + + /// + /// The offset (0-1) of the inner (center-facing) stop. Non-zero for a donut so the gradient spans + /// the visible ring rather than the whole radius. + /// + [Parameter] + public double InnerOffset { get; set; } + + /// + /// The opacity at the inner stop. + /// + [Parameter] + public double InnerOpacity { get; set; } = 1.0; + + /// + /// The offset (0-1) of the optional middle stop. When null only two stops are rendered. + /// + [Parameter] + public double? MidOffset { get; set; } + + /// + /// The opacity at the middle stop. Used only when is set. + /// + [Parameter] + public double MidOpacity { get; set; } = 1.0; + + /// + /// The opacity at the rim stop. + /// + [Parameter] + public double EndOpacity { get; set; } = 1.0; +}