RadzenPyramidSeries added

This commit is contained in:
Vladimir Enchev
2026-03-16 18:29:57 +02:00
parent c3afc1b0f7
commit 3facb1c80e
7 changed files with 425 additions and 3 deletions

View File

@@ -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;
});
}

View File

@@ -0,0 +1,39 @@
@using Radzen.Blazor.Rendering
@typeparam TItem
@inherits Radzen.Blazor.CartesianSeries<TItem>
<CascadingValue Value="@this">
@ChildContent
</CascadingValue>
@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
@<g class="@className">
@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}";
<g class="@arcClassName">
<Path D="@d" Fill="@fill" StrokeWidth="@StrokeWidth" Stroke="@stroke" />
</g>
}
}
</g>;
}
}

View File

@@ -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
{
/// <summary>
/// A chart series that displays data as a pyramid chart with trapezoid segments, widest at the bottom.
/// </summary>
/// <typeparam name="TItem">The type of data items in the series.</typeparam>
public partial class RadzenPyramidSeries<TItem> : CartesianSeries<TItem>
{
/// <summary>
/// Gets or sets a collection of fill colors applied to individual pyramid segments.
/// </summary>
[Parameter]
public IEnumerable<string>? Fills { get; set; }
/// <summary>
/// Gets or sets a collection of stroke colors applied to individual pyramid segments.
/// </summary>
[Parameter]
public IEnumerable<string>? Strokes { get; set; }
/// <summary>
/// Gets or sets the width of segment borders in pixels.
/// </summary>
[Parameter]
public double StrokeWidth { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to show labels on each segment.
/// </summary>
[Parameter]
public bool ShowLabels { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether to invert the pyramid so the wide base is at the top.
/// When <c>false</c> (default), the wide base is at the bottom. When <c>true</c>, the wide base is at the top.
/// </summary>
[Parameter]
public bool Inverted { get; set; }
/// <inheritdoc />
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<TItem> 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();
}
/// <summary>
/// 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.
/// </summary>
private double[] GetCumulativeYFractions(IList<TItem> 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;
}
/// <inheritdoc />
public override double MeasureLegend()
{
if (Items.Any())
{
return Items.Select(item => TextMeasurer.TextWidth(TooltipTitle(item))).Max() + MarkerSize;
}
return 0;
}
/// <inheritdoc />
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<LegendItem>(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,
});
}
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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 });
}
/// <inheritdoc />
protected override string TooltipClass(TItem item)
{
var sorted = GetSortedItems();
return $"{base.TooltipClass(item)} rz-pyramid-tooltip rz-series-item-{sorted.IndexOf(item)}";
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
internal override double TooltipX(TItem item)
{
return CenterX;
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
public override IEnumerable<ChartDataLabel> GetDataLabels(double offsetX, double offsetY)
{
var list = new List<ChartDataLabel>();
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<TItem> 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";
}
}
}

View File

@@ -1,7 +1,6 @@
<RadzenChart Style="width: 100%; height: 400px;">
<RadzenFunnelSeries Data="@stages" CategoryProperty="Stage" ValueProperty="Count"
Fills="@(new[] { "var(--rz-series-1)", "var(--rz-series-2)", "var(--rz-series-3)", "var(--rz-series-4)", "var(--rz-series-5)" })"
StrokeWidth="1" Strokes="@(new[] { "white", "white", "white", "white", "white" })">
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)" })">
<RadzenSeriesDataLabels Visible="true" />
</RadzenFunnelSeries>
<RadzenLegend Position="LegendPosition.Bottom" />

View File

@@ -0,0 +1,32 @@
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem" class="rz-mb-4">
<RadzenCheckBox @bind-Value="inverted" Name="inverted" />
<RadzenLabel Text="Inverted" Component="inverted" />
</RadzenStack>
<RadzenChart Style="width: 100%; height: 500px;">
<RadzenPyramidSeries Data="@levels" CategoryProperty="Level" ValueProperty="Count"
Inverted="@inverted"
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)" })">
<RadzenSeriesDataLabels Visible="true" />
</RadzenPyramidSeries>
<RadzenLegend Position="LegendPosition.Right" />
</RadzenChart>
@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 },
};
}

View File

@@ -0,0 +1,13 @@
@page "/pyramid-chart"
<RadzenText TextStyle="TextStyle.H2" TagName="TagName.H1" class="rz-pt-8">
Radzen Blazor Chart pyramid series
</RadzenText>
<RadzenText TextStyle="TextStyle.Subtitle1" TagName="TagName.P" class="rz-pb-4">
Display hierarchical data as a pyramid with the widest segment at the bottom. Useful for showing proportional or hierarchical relationships.
</RadzenText>
<RadzenExample ComponentName="Chart" Example="PyramidChartConfig"
DocumentationLink="https://blazor.radzen.com/docs/guides/components/chart.html">
<PyramidChartConfig />
</RadzenExample>

View File

@@ -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",