Add opt-in radial gradient fills to pie and donut chart series

This commit is contained in:
yordanov
2026-06-17 18:00:26 +03:00
parent cae0e7d3a1
commit 82e1e5ac06
6 changed files with 353 additions and 5 deletions

View File

@@ -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, "<radialGradient").Count;
static async Task<string> RenderPie(TestContext ctx, FillMode fillMode = FillMode.Gradient, string[]? fills = null)
{
var chart = ctx.RenderComponent<RadzenChart>(p =>
p.AddChildContent<RadzenPieSeries<DataItem>>(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<string> RenderDonut(TestContext ctx, FillMode fillMode = FillMode.Gradient, string[]? fills = null)
{
var chart = ctx.RenderComponent<RadzenChart>(p =>
p.AddChildContent<RadzenDonutSeries<DataItem>>(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<RadzenChart>(p =>
p.AddChildContent<RadzenPieSeries<DataItem>>(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("<radialGradient[^>]*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").Count);
// Default stops: full color out to 40% of the radius (0.4), faded middle (0.9), rim (1).
Assert.Matches("offset=\"0.4\"[^>]*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<RadzenChart>(p =>
p.AddChildContent<RadzenPieSeries<DataItem>>(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").Count);
Assert.Matches("offset=\"0.4\"[^>]*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("<radialGradient[^>]*gradientUnits=\"userSpaceOnUse\"", markup);
}
[Fact]
public async Task Donut_SolidByDefault_RendersNoGradient()
{
using var ctx = CreateChartContext();
var chart = ctx.RenderComponent<RadzenChart>(p =>
p.AddChildContent<RadzenDonutSeries<DataItem>>(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));
}
}
}

View File

@@ -2,7 +2,7 @@
{
/// <summary>
/// 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.
/// </summary>
public enum FillMode
{

View File

@@ -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" : "";
<g class="@arcClassName" style="@($"--rz-segment-order: {index};{explodeStyle}")">
@if (gradient)
{
<RadialGradientDef Id="@gradId" Color="@gradientColor" Cx="@x" Cy="@y" R="@radius" InnerOffset="@GradientInnerOffset" InnerOpacity="@GradientEndOpacity" MidOffset="@GradientMidOffset" MidOpacity="@GradientStartOpacity" EndOpacity="@GradientRimOpacity" />
}
@if (ExplodeOffset > 0)
{
<path class="rz-hit-area" d="@d" style="fill: transparent; stroke: none; pointer-events: fill" />
}
<Path D="@d" Fill="@fill" StrokeWidth="@StrokeWidth" Stroke="@stroke" />
<Path D="@d" Fill="@fillRef" StrokeWidth="@StrokeWidth" Stroke="@stroke" />
</g>
}
}

View File

@@ -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" : "";
<g class="@arcClassName" style="@($"--rz-segment-order: {index};{explodeStyle}")">
@if (sweepAngle > 0)
{
@if (gradient)
{
<RadialGradientDef Id="@gradId" Color="@gradientColor" Cx="@x" Cy="@y" R="@radius" InnerOffset="@GradientInnerOffset" InnerOpacity="@GradientEndOpacity" MidOffset="@GradientMidOffset" MidOpacity="@GradientStartOpacity" EndOpacity="@GradientRimOpacity" />
}
@if (ExplodeOffset > 0)
{
<path class="rz-hit-area" d="@d" style="fill: transparent; stroke: none; pointer-events: fill" />
}
<Path D="@d" Fill="@fill" StrokeWidth="@StrokeWidth" Stroke="@stroke" />
<Path D="@d" Fill="@fillRef" StrokeWidth="@StrokeWidth" Stroke="@stroke" />
}
</g>
}

View File

@@ -105,6 +105,51 @@ namespace Radzen.Blazor
[Parameter]
public double StrokeWidth { get; set; }
/// <summary>
/// Specifies how the segments are filled. Set to <see cref="FillMode.Solid"/> by default.
/// Use <see cref="FillMode.Gradient"/> 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 <see cref="FillMode.None"/> to render only the outline.
/// </summary>
/// <value>The fill mode. Default is <see cref="FillMode.Solid"/>.</value>
[Parameter]
public FillMode FillMode { get; set; } = FillMode.Solid;
/// <summary>
/// Specifies the opacity of the faded middle stop of the gradient.
/// Below <c>1</c> it lets the chart background show through, so it eases lighter on light themes and darker on dark themes. Used when <see cref="FillMode"/> is <see cref="FillMode.Gradient"/>.
/// </summary>
/// <value>The gradient start opacity. Default is <c>0.62</c>.</value>
[Parameter]
public double GradientStartOpacity { get; set; } = 0.62;
/// <summary>
/// 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 <see cref="FillMode"/> is <see cref="FillMode.Gradient"/>.
/// </summary>
/// <value>The gradient end opacity. Default is <c>1</c>.</value>
[Parameter]
public double GradientEndOpacity { get; set; } = 1.0;
/// <summary>
/// Specifies the radius fraction (0-1) at which the gradient holds the full color before it starts to fade. Used when <see cref="FillMode"/> is <see cref="FillMode.Gradient"/>.
/// </summary>
/// <value>The gradient inner offset. Default is <c>0.4</c>.</value>
[Parameter]
public double GradientInnerOffset { get; set; } = 0.4;
/// <summary>
/// Specifies the radius fraction (0-1) of the gradient's faded middle stop. Used when <see cref="FillMode"/> is <see cref="FillMode.Gradient"/>.
/// </summary>
/// <value>The gradient middle offset. Default is <c>0.9</c>.</value>
[Parameter]
public double GradientMidOffset { get; set; } = 0.9;
/// <summary>
/// Specifies the opacity at the very rim of the gradient. Used when <see cref="FillMode"/> is <see cref="FillMode.Gradient"/>.
/// </summary>
/// <value>The gradient rim opacity. Default is <c>0.7</c>.</value>
[Parameter]
public double GradientRimOpacity { get; set; } = 0.7;
/// <summary>
/// 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.

View File

@@ -0,0 +1,74 @@
@using Radzen.Blazor.Rendering
<defs>
<radialGradient id="@Id" gradientUnits="userSpaceOnUse" cx="@Cx.ToInvariantString()" cy="@Cy.ToInvariantString()" r="@R.ToInvariantString()">
<stop offset="@InnerOffset.ToInvariantString()" style="@($"stop-color: {Color}; stop-opacity: {InnerOpacity.ToInvariantString()}")"></stop>
@if (MidOffset.HasValue)
{
<stop offset="@MidOffset.Value.ToInvariantString()" style="@($"stop-color: {Color}; stop-opacity: {MidOpacity.ToInvariantString()}")"></stop>
}
<stop offset="1" style="@($"stop-color: {Color}; stop-opacity: {EndOpacity.ToInvariantString()}")"></stop>
</radialGradient>
</defs>
@code {
/// <summary>
/// The id of the gradient, referenced by the shape via <c>fill: url(#id)</c>.
/// </summary>
[Parameter]
public string Id { get; set; } = string.Empty;
/// <summary>
/// The gradient color, used by both stops (only the opacity varies). May be a literal color or a
/// CSS variable such as <c>var(--rz-series-color)</c>, resolved against the element this renders inside.
/// </summary>
[Parameter]
public string Color { get; set; } = string.Empty;
/// <summary>
/// The x coordinate of the gradient center, in user space (the chart SVG coordinate system).
/// </summary>
[Parameter]
public double Cx { get; set; }
/// <summary>
/// The y coordinate of the gradient center, in user space.
/// </summary>
[Parameter]
public double Cy { get; set; }
/// <summary>
/// The radius of the gradient, in user space.
/// </summary>
[Parameter]
public double R { get; set; }
/// <summary>
/// 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.
/// </summary>
[Parameter]
public double InnerOffset { get; set; }
/// <summary>
/// The opacity at the inner stop.
/// </summary>
[Parameter]
public double InnerOpacity { get; set; } = 1.0;
/// <summary>
/// The offset (0-1) of the optional middle stop. When <c>null</c> only two stops are rendered.
/// </summary>
[Parameter]
public double? MidOffset { get; set; }
/// <summary>
/// The opacity at the middle stop. Used only when <see cref="MidOffset"/> is set.
/// </summary>
[Parameter]
public double MidOpacity { get; set; } = 1.0;
/// <summary>
/// The opacity at the rim stop.
/// </summary>
[Parameter]
public double EndOpacity { get; set; } = 1.0;
}