mirror of
https://github.com/radzenhq/radzen-blazor.git
synced 2026-07-08 18:16:08 +00:00
Add opt-in radial gradient fills to pie and donut chart series
This commit is contained in:
211
Radzen.Blazor.Tests/PieDonutGradientTests.cs
Normal file
211
Radzen.Blazor.Tests/PieDonutGradientTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
74
Radzen.Blazor/Rendering/RadialGradientDef.razor
Normal file
74
Radzen.Blazor/Rendering/RadialGradientDef.razor
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user