From f2ec0428ade8b57b1aa0678a1f6b032ffc86d182 Mon Sep 17 00:00:00 2001 From: yordanov Date: Sun, 21 Jun 2026 16:21:29 +0300 Subject: [PATCH] Add corner radius and exploded slices to pie and donut series --- Radzen.Blazor.Tests/PieSeriesTests.cs | 190 ++++++++++++++++++ Radzen.Blazor/RadzenDonutSeries.razor | 3 +- Radzen.Blazor/RadzenPieSeries.razor | 3 +- Radzen.Blazor/RadzenPieSeries.razor.cs | 175 +++++++++++++++- .../themes/components/blazor/_chart.scss | 24 +++ .../Pages/DonutChartConfig.razor | 8 +- .../Pages/DonutChartExplode.razor | 5 +- RadzenBlazorDemos/Pages/PieChartConfig.razor | 8 +- RadzenBlazorDemos/Pages/PieChartExplode.razor | 5 +- 9 files changed, 411 insertions(+), 10 deletions(-) diff --git a/Radzen.Blazor.Tests/PieSeriesTests.cs b/Radzen.Blazor.Tests/PieSeriesTests.cs index 47e9a4e29..c717a4eb4 100644 --- a/Radzen.Blazor.Tests/PieSeriesTests.cs +++ b/Radzen.Blazor.Tests/PieSeriesTests.cs @@ -109,5 +109,195 @@ namespace Radzen.Blazor.Tests // Donut arcs have both outer and inner radius in the path Assert.Contains("A 118 118", chart.Markup); // outer } + + [Fact] + public async System.Threading.Tasks.Task PieSeries_DefaultCornerRadius_UsesSharpPath() + { + using var ctx = CreateChartContext(); + + var chart = ctx.RenderComponent(p => p + .AddChildContent>(s => s + .Add(x => x.CategoryProperty, nameof(DataItem.Category)) + .Add(x => x.ValueProperty, nameof(DataItem.Value)) + .Add(x => x.Data, SampleData))); + + await chart.InvokeAsync(() => chart.Instance.Resize(400, 300)); + + // A sharp pie slice closes through the center with a zero-radius inner arc. + Assert.Contains("A 0 0", chart.Markup); + } + + [Fact] + public async System.Threading.Tasks.Task PieSeries_CornerRadius_RoundsThreeCornersPerSlice() + { + using var ctx = CreateChartContext(); + + var chart = ctx.RenderComponent(p => p + .AddChildContent>(s => s + .Add(x => x.CategoryProperty, nameof(DataItem.Category)) + .Add(x => x.ValueProperty, nameof(DataItem.Value)) + .Add(x => x.CornerRadius, 10.0) + .Add(x => x.Data, SampleData))); + + await chart.InvokeAsync(() => chart.Instance.Resize(400, 300)); + + // No sharp apex (no zero-radius inner arc) once corners are rounded. + Assert.DoesNotContain("A 0 0", chart.Markup); + // Three fillet arcs of radius 10 per slice (two outer corners + apex) across three slices. + Assert.Equal(9, Count(chart.Markup, "A 10 10")); + Assert.DoesNotContain("NaN", chart.Markup); + } + + [Fact] + public async System.Threading.Tasks.Task DonutSeries_CornerRadius_RoundsFourCornersPerSlice() + { + using var ctx = CreateChartContext(); + + var chart = ctx.RenderComponent(p => p + .AddChildContent>(s => s + .Add(x => x.CategoryProperty, nameof(DataItem.Category)) + .Add(x => x.ValueProperty, nameof(DataItem.Value)) + .Add(x => x.InnerRadius, 50) + .Add(x => x.CornerRadius, 10.0) + .Add(x => x.Data, SampleData))); + + await chart.InvokeAsync(() => chart.Instance.Resize(400, 300)); + + // Main outer and inner arcs survive. + Assert.Contains("A 118 118", chart.Markup); + Assert.Contains("A 50 50", chart.Markup); + // Four fillet arcs of radius 10 per slice across three slices. + Assert.Equal(12, Count(chart.Markup, "A 10 10")); + Assert.DoesNotContain("NaN", chart.Markup); + } + + [Fact] + public async System.Threading.Tasks.Task PieSeries_LargeCornerRadius_ClampedWithoutNaN() + { + using var ctx = CreateChartContext(); + + var chart = ctx.RenderComponent(p => p + .AddChildContent>(s => s + .Add(x => x.CategoryProperty, nameof(DataItem.Category)) + .Add(x => x.ValueProperty, nameof(DataItem.Value)) + .Add(x => x.CornerRadius, 1000.0) + .Add(x => x.Data, SampleData))); + + await chart.InvokeAsync(() => chart.Instance.Resize(400, 300)); + + Assert.DoesNotContain("NaN", chart.Markup); + } + + [Fact] + public async System.Threading.Tasks.Task PieSeries_SingleItem_CornerRadiusFallsBackToSharpRing() + { + using var ctx = CreateChartContext(); + + var single = new[] { new DataItem { Category = "A", Value = 10 } }; + + var chart = ctx.RenderComponent(p => p + .AddChildContent>(s => s + .Add(x => x.CategoryProperty, nameof(DataItem.Category)) + .Add(x => x.ValueProperty, nameof(DataItem.Value)) + .Add(x => x.CornerRadius, 10.0) + .Add(x => x.Data, single))); + + await chart.InvokeAsync(() => chart.Instance.Resize(400, 300)); + + // A full-circle slice has no corners; it renders as the sharp ring (zero-radius inner arc). + Assert.Contains("A 0 0", chart.Markup); + Assert.DoesNotContain("A 10 10", chart.Markup); + } + + class ExplodeItem + { + public string Category { get; set; } = ""; + public double Value { get; set; } + public bool Exploded { get; set; } + } + + static ExplodeItem[] ExplodeData => new[] + { + new ExplodeItem { Category = "A", Value = 10, Exploded = false }, + new ExplodeItem { Category = "B", Value = 20, Exploded = true }, + new ExplodeItem { Category = "C", Value = 15, Exploded = false }, + }; + + [Fact] + public void PieSeries_ExplodedProperty_MarksFlaggedSlices() + { + using var ctx = CreateChartContext(); + + var chart = ctx.RenderComponent(p => p + .AddChildContent>(s => s + .Add(x => x.CategoryProperty, nameof(ExplodeItem.Category)) + .Add(x => x.ValueProperty, nameof(ExplodeItem.Value)) + .Add(x => x.ExplodeOffset, 15.0) + .Add(x => x.ExplodedProperty, nameof(ExplodeItem.Exploded)) + .Add(x => x.Data, ExplodeData))); + + // Only the single flagged slice carries the static-explode class, and the offset vars are emitted. + Assert.Equal(1, Count(chart.Markup, "rz-state-exploded")); + Assert.Contains("--rz-explode-x", chart.Markup); + } + + [Fact] + public void PieSeries_NoExplodedProperty_NoStaticExplodeClass() + { + using var ctx = CreateChartContext(); + + var chart = ctx.RenderComponent(p => p + .AddChildContent>(s => s + .Add(x => x.CategoryProperty, nameof(ExplodeItem.Category)) + .Add(x => x.ValueProperty, nameof(ExplodeItem.Value)) + .Add(x => x.ExplodeOffset, 15.0) + .Add(x => x.Data, ExplodeData))); + + Assert.DoesNotContain("rz-state-exploded", chart.Markup); + } + + [Fact] + public void PieSeries_ExplodedProperty_WithoutOffset_NoStaticExplodeClass() + { + using var ctx = CreateChartContext(); + + var chart = ctx.RenderComponent(p => p + .AddChildContent>(s => s + .Add(x => x.CategoryProperty, nameof(ExplodeItem.Category)) + .Add(x => x.ValueProperty, nameof(ExplodeItem.Value)) + .Add(x => x.ExplodedProperty, nameof(ExplodeItem.Exploded)) + .Add(x => x.Data, ExplodeData))); + + // ExplodeOffset defaults to 0, which gates static explode off. + Assert.DoesNotContain("rz-state-exploded", chart.Markup); + } + + [Fact] + public void DonutSeries_ExplodedProperty_MarksFlaggedSlices() + { + using var ctx = CreateChartContext(); + + var chart = ctx.RenderComponent(p => p + .AddChildContent>(s => s + .Add(x => x.CategoryProperty, nameof(ExplodeItem.Category)) + .Add(x => x.ValueProperty, nameof(ExplodeItem.Value)) + .Add(x => x.ExplodeOffset, 15.0) + .Add(x => x.ExplodedProperty, nameof(ExplodeItem.Exploded)) + .Add(x => x.Data, ExplodeData))); + + Assert.Equal(1, Count(chart.Markup, "rz-state-exploded")); + } + + private static int Count(string haystack, string needle) + { + var count = 0; + var index = 0; + while ((index = haystack.IndexOf(needle, index, System.StringComparison.Ordinal)) >= 0) + { + count++; + index += needle.Length; + } + return count; + } } } diff --git a/Radzen.Blazor/RadzenDonutSeries.razor b/Radzen.Blazor/RadzenDonutSeries.razor index 4ddd46e8f..319a4db45 100644 --- a/Radzen.Blazor/RadzenDonutSeries.razor +++ b/Radzen.Blazor/RadzenDonutSeries.razor @@ -63,7 +63,8 @@ startAngle = endAngle; var index = Items.IndexOf(data); - var arcClassName = $"rz-series-item-{index}"; + var exploded = ExplodeOffset > 0 && IsExploded(data); + var arcClassName = $"rz-series-item-{index}" + (exploded ? " rz-state-exploded" : ""); var fill = PickColor(index, Fills); var stroke = PickColor(index, Strokes); var gradId = $"rzPieGradient{chart.UniqueID}-{seriesIndex}-{index}"; diff --git a/Radzen.Blazor/RadzenPieSeries.razor b/Radzen.Blazor/RadzenPieSeries.razor index bf2c92acd..ac3f164dd 100644 --- a/Radzen.Blazor/RadzenPieSeries.razor +++ b/Radzen.Blazor/RadzenPieSeries.razor @@ -42,7 +42,8 @@ startAngle = endAngle; var index = Items.IndexOf(data); - var arcClassName = $"rz-series-item-{index}"; + var exploded = ExplodeOffset > 0 && IsExploded(data); + var arcClassName = $"rz-series-item-{index}" + (exploded ? " rz-state-exploded" : ""); var fill = PickColor(index, Fills); var stroke = PickColor(index, Strokes); var gradId = $"rzPieGradient{chart.UniqueID}-{seriesIndex}-{index}"; diff --git a/Radzen.Blazor/RadzenPieSeries.razor.cs b/Radzen.Blazor/RadzenPieSeries.razor.cs index 0d600bdab..ddf385cbb 100644 --- a/Radzen.Blazor/RadzenPieSeries.razor.cs +++ b/Radzen.Blazor/RadzenPieSeries.razor.cs @@ -63,6 +63,15 @@ namespace Radzen.Blazor [Parameter] public double? Radius { get; set; } + /// + /// Gets or sets the corner radius in pixels used to round the corners of each segment. + /// A pie segment has its two outer corners and its center apex rounded; a donut segment has all four corners rounded. + /// The value is clamped per segment so that adjacent corners never overlap. + /// + /// The corner radius in pixels. Default is 0 (sharp corners). + [Parameter] + public double CornerRadius { get; set; } + /// /// Gets or sets the name of the property that provides a per-item radius value. /// When set, each pie/donut segment can have a different outer radius, allowing visual differentiation by size. @@ -74,12 +83,23 @@ namespace Radzen.Blazor /// /// Gets or sets the distance in pixels that a segment moves outward from the center when hovered. - /// Set to a value greater than 0 to enable the explode-on-hover effect. + /// Set to a value greater than 0 to enable the explode-on-hover effect. Also sets the distance for + /// segments pulled out permanently via . /// /// The explode offset in pixels. Default is 0 (disabled). [Parameter] public double ExplodeOffset { get; set; } + /// + /// Gets or sets the name of a boolean property that marks segments as exploded (pulled out from the + /// center) in their normal state, not just on hover. Requires greater than 0, + /// which sets the distance. Exploded segments stay pulled out and move a little further on hover; other + /// segments still explode on hover. + /// + /// The boolean property name on , or null to disable static explode. + [Parameter] + public string? ExplodedProperty { get; set; } + /// /// Gets or sets a collection of fill colors applied to individual pie segments in sequence. /// Each segment gets the color at its index position. If fewer colors than segments, colors are reused cyclically. @@ -222,6 +242,20 @@ namespace Radzen.Blazor return minRadius + (value - minValue) / (maxValue - minValue) * (maxRadius - minRadius); } + /// + /// Returns whether a specific data item is exploded in its normal state, as determined by + /// . Always false when is not set. + /// + internal bool IsExploded(TItem item) + { + if (string.IsNullOrEmpty(ExplodedProperty)) + { + return false; + } + + return PropertyAccess.Getter(ExplodedProperty)(item); + } + /// /// Gets the current X coordinate of the center. /// @@ -511,7 +545,7 @@ namespace Radzen.Blazor } /// - /// Creates SVG path that renders the specified segment. + /// Creates SVG path that renders the specified segment, rounding its corners by . /// /// The x. /// The y. @@ -520,6 +554,14 @@ namespace Radzen.Blazor /// The start angle. /// The end angle. protected string Segment(double x, double y, double radius, double innerRadius, double startAngle, double endAngle) + { + return Segment(x, y, radius, innerRadius, startAngle, endAngle, CornerRadius); + } + + /// + /// Creates SVG path that renders the specified segment with sharp corners. + /// + private string SharpSegment(double x, double y, double radius, double innerRadius, double startAngle, double endAngle) { var largeArcFlag = 0; @@ -549,6 +591,135 @@ namespace Radzen.Blazor return $"M {startX} {startY} A {r} {r} 0 {largeArcFlag} 1 {endX} {endY} L {innerEndX} {innerEndY} A {innerR} {innerR} 0 {largeArcFlag} 0 {innerStartX} {innerStartY} Z"; } + /// + /// Creates SVG path that renders the specified segment with corners rounded by . + /// A pie segment ( of 0) rounds its two outer corners and its center apex; + /// a donut segment rounds all four corners. The corner radius is clamped per segment so corners never overlap. + /// + /// The x. + /// The y. + /// The radius. + /// The inner radius. + /// The start angle. + /// The end angle. + /// The corner radius in pixels. + protected string Segment(double x, double y, double radius, double innerRadius, double startAngle, double endAngle, double cornerRadius) + { + var sweep = Math.Abs(startAngle - endAngle); + + // No rounding requested, a full ring (no corners to round), or a degenerate radius: use the sharp path. + if (cornerRadius <= 0.01 || sweep >= 359.99 || radius <= 0) + { + return SharpSegment(x, y, radius, innerRadius, startAngle, endAngle); + } + + var isDonut = innerRadius > 0; + + // Clamp the corner radius so adjacent fillets never overlap. + var s = Math.Sin(DegToRad(sweep / 2)); + var rc = Math.Min(cornerRadius, (radius - innerRadius) / 2); + rc = Math.Min(rc, radius * s / (1 + s)); // outer arc angular limit + if (isDonut && s < 1) + { + rc = Math.Min(rc, innerRadius * s / (1 - s)); // inner arc angular limit + } + + // Too thin to round visibly - fall back to sharp corners. + if (rc < 0.01) + { + return SharpSegment(x, y, radius, innerRadius, startAngle, endAngle); + } + + // The slice spans from startAngle (a0) down to endAngle (a1), drawn clockwise. + var a0 = startAngle; + var a1 = endAngle; + + // Outer fillets are tangent to the outer circle from inside (center at radius - rc). + var eOut = Math.Asin(rc / (radius - rc)); + var eOutDeg = eOut * 180 / Math.PI; + var outerEdgeRadius = (radius - rc) * Math.Cos(eOut); + + var outerArcStart = ToCartesian(x, y, radius, a0 - eOutDeg); + var outerArcEnd = ToCartesian(x, y, radius, a1 + eOutDeg); + var outerEdgeStart = ToCartesian(x, y, outerEdgeRadius, a0); + var outerEdgeEnd = ToCartesian(x, y, outerEdgeRadius, a1); + var outerCenterStart = ToCartesian(x, y, radius - rc, a0 - eOutDeg); + var outerCenterEnd = ToCartesian(x, y, radius - rc, a1 + eOutDeg); + + var largeOuter = (sweep - 2 * eOutDeg) >= 180 ? 1 : 0; + + var path = $"M {Format(outerArcStart)}" + + $" {Arc(radius, largeOuter, 1, outerArcEnd)}" + + $" {Arc(rc, 0, Sweep(outerArcEnd, outerEdgeEnd, outerCenterEnd), outerEdgeEnd)}"; + + if (isDonut) + { + // Inner fillets are tangent to the inner circle from outside (center at innerRadius + rc). + var eIn = Math.Asin(rc / (innerRadius + rc)); + var eInDeg = eIn * 180 / Math.PI; + var innerEdgeRadius = (innerRadius + rc) * Math.Cos(eIn); + + var innerArcStart = ToCartesian(x, y, innerRadius, a0 - eInDeg); + var innerArcEnd = ToCartesian(x, y, innerRadius, a1 + eInDeg); + var innerEdgeStart = ToCartesian(x, y, innerEdgeRadius, a0); + var innerEdgeEnd = ToCartesian(x, y, innerEdgeRadius, a1); + var innerCenterStart = ToCartesian(x, y, innerRadius + rc, a0 - eInDeg); + var innerCenterEnd = ToCartesian(x, y, innerRadius + rc, a1 + eInDeg); + + var largeInner = (sweep - 2 * eInDeg) >= 180 ? 1 : 0; + + path += $" L {Format(innerEdgeEnd)}" + + $" {Arc(rc, 0, Sweep(innerEdgeEnd, innerArcEnd, innerCenterEnd), innerArcEnd)}" + + $" {Arc(innerRadius, largeInner, 0, innerArcStart)}" + + $" {Arc(rc, 0, Sweep(innerArcStart, innerEdgeStart, innerCenterStart), innerEdgeStart)}" + + $" L {Format(outerEdgeStart)}" + + $" {Arc(rc, 0, Sweep(outerEdgeStart, outerArcStart, outerCenterStart), outerArcStart)} Z"; + } + else + { + // Pie: round the center apex where the two radial edges meet. + var half = DegToRad(sweep / 2); + var apexEdgeRadius = rc / Math.Tan(half); + var apexStart = ToCartesian(x, y, apexEdgeRadius, a0); + var apexEnd = ToCartesian(x, y, apexEdgeRadius, a1); + var apexCenter = ToCartesian(x, y, rc / Math.Sin(half), (a0 + a1) / 2); + + path += $" L {Format(apexEnd)}" + + $" {Arc(rc, 0, Sweep(apexEnd, apexStart, apexCenter), apexStart)}" + + $" L {Format(outerEdgeStart)}" + + $" {Arc(rc, 0, Sweep(outerEdgeStart, outerArcStart, outerCenterStart), outerArcStart)} Z"; + } + + return path; + } + + /// + /// Formats a point as "x y" using the invariant culture. + /// + private static string Format((double X, double Y) point) + { + return $"{point.X.ToInvariantString()} {point.Y.ToInvariantString()}"; + } + + /// + /// Builds an SVG elliptical-arc command to the given end point. + /// + private static string Arc(double radius, int largeArcFlag, int sweepFlag, (double X, double Y) end) + { + var r = radius.ToInvariantString(); + return $"A {r} {r} 0 {largeArcFlag} {sweepFlag} {Format(end)}"; + } + + /// + /// Returns the SVG sweep flag for the minor arc from to + /// about . In SVG (y-down) coordinates a positive cross product is drawn clockwise. + /// + private static int Sweep((double X, double Y) start, (double X, double Y) end, (double X, double Y) center) + { + var cross = (start.X - center.X) * (end.Y - center.Y) - (start.Y - center.Y) * (end.X - center.X); + return cross > 0 ? 1 : 0; + } + /// /// Returns the inner radius used for data label placement - 0 for pie, the hole radius for donut. /// diff --git a/Radzen.Blazor/themes/components/blazor/_chart.scss b/Radzen.Blazor/themes/components/blazor/_chart.scss index 81ba992f6..4555b5de2 100644 --- a/Radzen.Blazor/themes/components/blazor/_chart.scss +++ b/Radzen.Blazor/themes/components/blazor/_chart.scss @@ -337,6 +337,17 @@ $chart-color-schemes: ( } } } + + // Segments exploded in their normal state move a little further on hover. + > g.rz-state-exploded { + &:hover, + &.rz-state-hover { + > path:not(.rz-hit-area), + > .rz-path { + transform: translate(calc(var(--rz-explode-x, 0) * 1.2), calc(var(--rz-explode-y, 0) * 1.2)); + } + } + } } .rz-pie-series:has(> .rz-state-hover), @@ -367,6 +378,19 @@ $chart-color-schemes: ( } } +// Segments pulled out permanently via ExplodedProperty. Applied outside the hover wrapper so static +// explode works even when AllowSeriesHover is false. The hit-area moves too so it tracks the slice. +.rz-pie-series, +.rz-donut-series { + > g.rz-state-exploded { + > path, + > .rz-path { + transform: translate(var(--rz-explode-x, 0), var(--rz-explode-y, 0)); + transition: transform var(--rz-transition); + } + } +} + @each $scheme, $colors in $chart-color-schemes { @each $color in $colors { $index: index($colors, $color); diff --git a/RadzenBlazorDemos/Pages/DonutChartConfig.razor b/RadzenBlazorDemos/Pages/DonutChartConfig.razor index ca72d74c7..1fd309850 100644 --- a/RadzenBlazorDemos/Pages/DonutChartConfig.razor +++ b/RadzenBlazorDemos/Pages/DonutChartConfig.razor @@ -19,12 +19,17 @@ + + + + @cornerRadius px + - + @@ -43,6 +48,7 @@ bool showDataLabels = false; bool showTooltipOnLegend = true; FillMode fillMode = FillMode.Gradient; + double cornerRadius = 0; class DataItem { diff --git a/RadzenBlazorDemos/Pages/DonutChartExplode.razor b/RadzenBlazorDemos/Pages/DonutChartExplode.razor index 2f88fe8b1..c0f79cf6e 100644 --- a/RadzenBlazorDemos/Pages/DonutChartExplode.razor +++ b/RadzenBlazorDemos/Pages/DonutChartExplode.razor @@ -1,7 +1,7 @@ - + @@ -16,6 +16,7 @@ { public string Quarter { get; set; } public double Revenue { get; set; } + public bool Highlight { get; set; } } DataItem[] revenue = new DataItem[] @@ -23,6 +24,6 @@ new DataItem { Quarter = "Q1", Revenue = 30000 }, new DataItem { Quarter = "Q2", Revenue = 40000 }, new DataItem { Quarter = "Q3", Revenue = 50000 }, - new DataItem { Quarter = "Q4", Revenue = 80000 }, + new DataItem { Quarter = "Q4", Revenue = 80000, Highlight = true }, }; } diff --git a/RadzenBlazorDemos/Pages/PieChartConfig.razor b/RadzenBlazorDemos/Pages/PieChartConfig.razor index 739ab5aec..d46e596b9 100644 --- a/RadzenBlazorDemos/Pages/PieChartConfig.razor +++ b/RadzenBlazorDemos/Pages/PieChartConfig.razor @@ -19,12 +19,17 @@ + + + + @cornerRadius px + - + @@ -38,6 +43,7 @@ bool showDataLabels = false; bool showTooltipOnLegend = true; FillMode fillMode = FillMode.Gradient; + double cornerRadius = 0; void OnSeriesClick(SeriesClickEventArgs args) { diff --git a/RadzenBlazorDemos/Pages/PieChartExplode.razor b/RadzenBlazorDemos/Pages/PieChartExplode.razor index 0abb3c587..d01e8d3a2 100644 --- a/RadzenBlazorDemos/Pages/PieChartExplode.razor +++ b/RadzenBlazorDemos/Pages/PieChartExplode.razor @@ -1,7 +1,7 @@ - + @@ -14,6 +14,7 @@ { public string Quarter { get; set; } public double Revenue { get; set; } + public bool Highlight { get; set; } } DataItem[] revenue = new DataItem[] @@ -21,6 +22,6 @@ new DataItem { Quarter = "Q1", Revenue = 30000 }, new DataItem { Quarter = "Q2", Revenue = 40000 }, new DataItem { Quarter = "Q3", Revenue = 50000 }, - new DataItem { Quarter = "Q4", Revenue = 80000 }, + new DataItem { Quarter = "Q4", Revenue = 80000, Highlight = true }, }; }