diff --git a/Radzen.Blazor/RadzenChart.razor.cs b/Radzen.Blazor/RadzenChart.razor.cs index 9d3bfee7e..fc3b4e8fe 100644 --- a/Radzen.Blazor/RadzenChart.razor.cs +++ b/Radzen.Blazor/RadzenChart.razor.cs @@ -153,12 +153,13 @@ namespace Radzen.Blazor var pieType = typeof(RadzenPieSeries<>); var donutType = typeof(RadzenDonutSeries<>); var funnelType = typeof(RadzenFunnelSeries<>); + var pyramidType = typeof(RadzenPyramidSeries<>); return !Series.All(series => { var type = series.GetType().GetGenericTypeDefinition(); - return type == pieType || type == donutType || type == funnelType; + return type == pieType || type == donutType || type == funnelType || type == pyramidType; }); } diff --git a/Radzen.Blazor/RadzenPyramidSeries.razor b/Radzen.Blazor/RadzenPyramidSeries.razor new file mode 100644 index 000000000..0af0894ca --- /dev/null +++ b/Radzen.Blazor/RadzenPyramidSeries.razor @@ -0,0 +1,39 @@ +@using Radzen.Blazor.Rendering +@typeparam TItem +@inherits Radzen.Blazor.CartesianSeries + + + @ChildContent + + +@code { + public override CoordinateSystem CoordinateSystem => CoordinateSystem.Polar; + + public override RenderFragment Render(ScaleBase categoryScale, ScaleBase valueScale) + { + var chart = RequireChart(); + var className = $"rz-pyramid-series rz-series-{chart.Series.IndexOf(this)}"; + var items = GetSortedItems(); + var count = items.Count; + + return + @ + @if (items.Any()) + { + @for (var i = 0; i < count; i++) + { + var data = items[i]; + var index = i; + var d = GetSegmentPath(index, items); + var fill = PickColor(index, Fills); + var stroke = PickColor(index, Strokes); + var arcClassName = $"rz-series-item-{index}"; + + + + + } + } + ; + } +} diff --git a/Radzen.Blazor/RadzenPyramidSeries.razor.cs b/Radzen.Blazor/RadzenPyramidSeries.razor.cs new file mode 100644 index 000000000..ecb5f637d --- /dev/null +++ b/Radzen.Blazor/RadzenPyramidSeries.razor.cs @@ -0,0 +1,330 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Radzen.Blazor.Rendering; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Radzen.Blazor +{ + /// + /// A chart series that displays data as a pyramid chart with trapezoid segments, widest at the bottom. + /// + /// The type of data items in the series. + public partial class RadzenPyramidSeries : CartesianSeries + { + /// + /// Gets or sets a collection of fill colors applied to individual pyramid segments. + /// + [Parameter] + public IEnumerable? Fills { get; set; } + + /// + /// Gets or sets a collection of stroke colors applied to individual pyramid segments. + /// + [Parameter] + public IEnumerable? Strokes { get; set; } + + /// + /// Gets or sets the width of segment borders in pixels. + /// + [Parameter] + public double StrokeWidth { get; set; } + + /// + /// Gets or sets a value indicating whether to show labels on each segment. + /// + [Parameter] + public bool ShowLabels { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to invert the pyramid so the wide base is at the top. + /// When false (default), the wide base is at the bottom. When true, the wide base is at the top. + /// + [Parameter] + public bool Inverted { get; set; } + + /// + public override string Color => "#000"; + + private double CenterX + { + get + { + if (Chart?.CategoryScale?.Output != null) + { + return Chart.CategoryScale.Output.Mid + 8; + } + return 0; + } + } + + private double AvailableWidth + { + get + { + if (Chart?.CategoryScale?.Output != null) + { + return Chart.CategoryScale.Output.End - Chart.CategoryScale.Output.Start; + } + return 0; + } + } + + private double AvailableHeight + { + get + { + if (Chart?.ValueScale?.Output != null) + { + return Chart.ValueScale.Output.End - Chart.ValueScale.Output.Start; + } + return 0; + } + } + + private double TopY + { + get + { + if (Chart?.ValueScale?.Output != null) + { + return Chart.ValueScale.Output.Start; + } + return 0; + } + } + + private IList GetSortedItems() + { + // Polar coordinate system inverts Y, so code index 0 renders at the visual bottom. + // Default: largest at index 0 → visual bottom (wide base). Inverted: smallest at index 0 → visual bottom (narrow). + return Inverted + ? Items.OrderBy(Value).ToList() + : Items.OrderByDescending(Value).ToList(); + } + + /// + /// Returns the cumulative Y-boundary fractions for each segment. Entry i is the top Y fraction of segment i; + /// the last entry is 1.0 (bottom of the pyramid). Segment heights are proportional to values. + /// + private double[] GetCumulativeYFractions(IList sortedItems) + { + var count = sortedItems.Count; + var totalValue = sortedItems.Sum(Value); + var fractions = new double[count + 1]; + fractions[0] = 0; + for (int i = 0; i < count; i++) + { + fractions[i + 1] = fractions[i] + (totalValue > 0 ? Value(sortedItems[i]) / totalValue : 1.0 / count); + } + return fractions; + } + + /// + public override double MeasureLegend() + { + if (Items.Any()) + { + return Items.Select(item => TextMeasurer.TextWidth(TooltipTitle(item))).Max() + MarkerSize; + } + + return 0; + } + + /// + protected override RenderFragment RenderLegendItem(bool clickable) + { + return builder => + { + for (int i = 0; i < Items.Count; i++) + { + var data = Items[i]; + var index = i; + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(LegendItem.Text), TooltipTitle(data)); + builder.AddAttribute(2, nameof(LegendItem.Class), $"rz-series-item-{index}"); + builder.AddAttribute(3, nameof(LegendItem.MarkerSize), MarkerSize); + builder.AddAttribute(4, nameof(LegendItem.MarkerType), MarkerType); + builder.AddAttribute(5, nameof(LegendItem.Color), PickColor(index, Fills)); + builder.AddAttribute(6, nameof(LegendItem.Click), EventCallback.Factory.Create(this, () => OnLegendClick(data!))); + builder.AddAttribute(7, nameof(LegendItem.Clickable), clickable); + builder.CloseComponent(); + } + }; + } + + private async Task OnLegendClick(object data) + { + var chart = RequireChart(); + if (chart?.LegendClick.HasDelegate == true) + { + await chart.LegendClick.InvokeAsync(new LegendClickEventArgs + { + Data = data, + Title = GetTitle(), + IsVisible = true, + }); + } + } + + /// + public override bool Contains(double x, double y, double tolerance) + { + if (!Items.Any()) return false; + + var minY = Math.Min(TopY, TopY + AvailableHeight); + var maxY = Math.Max(TopY, TopY + AvailableHeight); + + return x >= CenterX - AvailableWidth / 2 && x <= CenterX + AvailableWidth / 2 + && y >= minY && y <= maxY; + } + + /// + public override (object, Point) DataAt(double x, double y) + { + if (!Contains(x, y, 0)) return (default!, new Point()); + + var sorted = GetSortedItems(); + var count = sorted.Count; + if (count == 0) return (default!, new Point()); + + var fractions = GetCumulativeYFractions(sorted); + // AvailableHeight is negative (SVG Y-axis), so yFraction still maps 0→1 correctly + var yFraction = (y - TopY) / AvailableHeight; + yFraction = Math.Clamp(yFraction, 0, 1); + + for (int i = 0; i < count; i++) + { + if (yFraction <= fractions[i + 1]) + { + return (sorted[i]!, new Point { X = x, Y = y }); + } + } + + return (sorted[count - 1]!, new Point { X = x, Y = y }); + } + + /// + protected override string TooltipClass(TItem item) + { + var sorted = GetSortedItems(); + return $"{base.TooltipClass(item)} rz-pyramid-tooltip rz-series-item-{sorted.IndexOf(item)}"; + } + + /// + protected override string TooltipStyle(TItem item) + { + var style = base.TooltipStyle(item); + var sorted = GetSortedItems(); + var index = sorted.IndexOf(item); + if (index >= 0) + { + var color = PickColor(index, Fills); + if (color != null) + { + style = $"{style}; border-color: {color};"; + } + } + return style; + } + + /// + internal override double TooltipX(TItem item) + { + return CenterX; + } + + /// + internal override double TooltipY(TItem item) + { + var sorted = GetSortedItems(); + var index = sorted.IndexOf(item); + var fractions = GetCumulativeYFractions(sorted); + var midFraction = (fractions[index] + fractions[index + 1]) / 2; + return TopY + midFraction * AvailableHeight; + } + + /// + public override IEnumerable GetDataLabels(double offsetX, double offsetY) + { + var list = new List(); + + if (Data == null || !Items.Any()) return list; + + var sorted = GetSortedItems(); + var count = sorted.Count; + var fractions = GetCumulativeYFractions(sorted); + + for (int i = 0; i < count; i++) + { + var item = sorted[i]; + var value = Value(item); + var midFraction = (fractions[i] + fractions[i + 1]) / 2; + var segmentHeight = Math.Abs((fractions[i + 1] - fractions[i]) * AvailableHeight); + + // Skip labels for segments that are too thin to display text + if (segmentHeight < 16) + { + continue; + } + + var y = TopY + midFraction * AvailableHeight; + + var chart = RequireChart(); + if (chart != null) + { + list.Add(new ChartDataLabel + { + TextAnchor = "middle", + Position = new Point { X = CenterX + offsetX, Y = y + offsetY }, + Text = chart.ValueAxis.Format(chart.ValueScale, value) + }); + } + } + + return list; + } + + internal string GetSegmentPath(int index, IList sortedItems) + { + var cx = CenterX; + var baseWidth = AvailableWidth; + var fractions = GetCumulativeYFractions(sortedItems); + + // Y positions — segment height is proportional to its value + var topYFraction = fractions[index]; + var bottomYFraction = fractions[index + 1]; + var topY = this.TopY + topYFraction * AvailableHeight; + var bottomY = this.TopY + bottomYFraction * AvailableHeight; + + // Width at each Y follows the triangle's straight sides + double topWidthFraction; + double bottomWidthFraction; + + if (Inverted) + { + // Polar Y-inversion: wide base at visual top, apex at visual bottom + topWidthFraction = topYFraction; + bottomWidthFraction = bottomYFraction; + } + else + { + // Polar Y-inversion: apex at visual top, wide base at visual bottom + topWidthFraction = 1.0 - topYFraction; + bottomWidthFraction = 1.0 - bottomYFraction; + } + + var topWidth = baseWidth * topWidthFraction; + var bottomWidth = baseWidth * bottomWidthFraction; + + var x1 = cx - topWidth / 2; + var x2 = cx + topWidth / 2; + var x3 = cx + bottomWidth / 2; + var x4 = cx - bottomWidth / 2; + + return $"M {x1.ToInvariantString()} {topY.ToInvariantString()} L {x2.ToInvariantString()} {topY.ToInvariantString()} L {x3.ToInvariantString()} {bottomY.ToInvariantString()} L {x4.ToInvariantString()} {bottomY.ToInvariantString()} Z"; + } + } +} diff --git a/RadzenBlazorDemos/Pages/FunnelChart.razor b/RadzenBlazorDemos/Pages/FunnelChart.razor index 6e3d7fe19..b52727031 100644 --- a/RadzenBlazorDemos/Pages/FunnelChart.razor +++ b/RadzenBlazorDemos/Pages/FunnelChart.razor @@ -1,7 +1,6 @@ + StrokeWidth="1" Strokes="@(new[] { "var(--rz-base-background-color)", "var(--rz-base-background-color)", "var(--rz-base-background-color)", "var(--rz-base-background-color)", "var(--rz-base-background-color)" })"> diff --git a/RadzenBlazorDemos/Pages/PyramidChartConfig.razor b/RadzenBlazorDemos/Pages/PyramidChartConfig.razor new file mode 100644 index 000000000..197623aaf --- /dev/null +++ b/RadzenBlazorDemos/Pages/PyramidChartConfig.razor @@ -0,0 +1,32 @@ + + + + + + + + + + + + +@code { + bool inverted; + + class PyramidData + { + public string Level { get; set; } = ""; + public double Count { get; set; } + } + + PyramidData[] levels = new[] + { + new PyramidData { Level = "Physiological", Count = 1000 }, + new PyramidData { Level = "Safety", Count = 600 }, + new PyramidData { Level = "Love & Belonging", Count = 350 }, + new PyramidData { Level = "Esteem", Count = 150 }, + new PyramidData { Level = "Self-actualization", Count = 50 }, + }; +} diff --git a/RadzenBlazorDemos/Pages/PyramidChartPage.razor b/RadzenBlazorDemos/Pages/PyramidChartPage.razor new file mode 100644 index 000000000..7212c1e5c --- /dev/null +++ b/RadzenBlazorDemos/Pages/PyramidChartPage.razor @@ -0,0 +1,13 @@ +@page "/pyramid-chart" + + + Radzen Blazor Chart pyramid series + + + Display hierarchical data as a pyramid with the widest segment at the bottom. Useful for showing proportional or hierarchical relationships. + + + + + diff --git a/RadzenBlazorDemos/Services/ExampleService.cs b/RadzenBlazorDemos/Services/ExampleService.cs index a3f37c6af..d42f21c64 100644 --- a/RadzenBlazorDemos/Services/ExampleService.cs +++ b/RadzenBlazorDemos/Services/ExampleService.cs @@ -2135,6 +2135,14 @@ namespace RadzenBlazorDemos New = true }, new Example + { + Name = "Pyramid Chart", + Path = "pyramid-chart", + Description = "Radzen Blazor Chart with pyramid series for hierarchical data visualization.", + Tags = new [] { "chart", "graph", "pyramid", "hierarchy", "triangle" }, + New = true + }, + new Example { Name = "Donut Chart", Path = "donut-chart",