Files
radzen-blazor/Radzen.Blazor/RadzenChart.razor.cs
2026-06-15 18:43:32 +03:00

641 lines
23 KiB
C#

using Microsoft.JSInterop;
using Radzen.Blazor.Rendering;
using Microsoft.AspNetCore.Components;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using System.Linq;
namespace Radzen.Blazor
{
/// <summary>
/// A versatile chart component for visualizing data through various chart types including line, area, column, bar, pie, and donut series.
/// RadzenChart supports multiple series, customizable axes, legends, tooltips, data labels, markers, and interactive features.
/// Container for one or more chart series components. Each series (RadzenLineSeries, RadzenColumnSeries, RadzenAreaSeries, etc.) defines how data is visualized.
/// Supports Cartesian charts (Line, Area, Column, Bar, StackedColumn, StackedBar, StackedArea with X/Y axes), Pie charts (Pie and Donut series for showing proportions),
/// customization of color schemes/axis configuration/grid lines/legends/tooltips/data labels/markers, interactive click events on series and legend items with hover tooltips,
/// annotations including trend lines/mean/median/mode lines/value annotations, and responsive design that automatically adapts to container size.
/// Series are defined as child components within the RadzenChart. Configure axes using RadzenCategoryAxis and RadzenValueAxis, customize the legend with RadzenLegend, and add tooltips with RadzenChartTooltipOptions.
/// </summary>
/// <example>
/// Basic column chart:
/// <code>
/// &lt;RadzenChart&gt;
/// &lt;RadzenColumnSeries Data=@revenue CategoryProperty="Quarter" Title="Revenue" ValueProperty="Revenue" /&gt;
/// &lt;/RadzenChart&gt;
/// @code {
/// class DataItem
/// {
/// public string Quarter { get; set; }
/// public double Revenue { get; set; }
/// }
/// DataItem[] revenue = new DataItem[]
/// {
/// new DataItem { Quarter = "Q1", Revenue = 234000 },
/// new DataItem { Quarter = "Q2", Revenue = 284000 },
/// new DataItem { Quarter = "Q3", Revenue = 274000 },
/// new DataItem { Quarter = "Q4", Revenue = 294000 }
/// };
/// }
/// </code>
/// Chart with multiple series and custom legend:
/// <code>
/// &lt;RadzenChart&gt;
/// &lt;RadzenLineSeries Data=@sales2023 CategoryProperty="Month" ValueProperty="Amount" Title="2023 Sales" /&gt;
/// &lt;RadzenLineSeries Data=@sales2024 CategoryProperty="Month" ValueProperty="Amount" Title="2024 Sales" /&gt;
/// &lt;RadzenLegend Position="LegendPosition.Bottom" /&gt;
/// &lt;RadzenCategoryAxis Formatter="@(value =&gt; value.ToString())" /&gt;
/// &lt;RadzenValueAxis Formatter="@(value =&gt; value.ToString("C"))" /&gt;
/// &lt;/RadzenChart&gt;
/// </code>
/// </example>
public partial class RadzenChart : RadzenComponent
{
/// <summary>
/// Gets or sets the color scheme used to assign colors to chart series.
/// Determines the palette of colors applied sequentially to each series when series-specific colors are not set.
/// Available schemes include Pastel (default), Palette, Monochrome, and custom color schemes.
/// </summary>
/// <value>The color scheme. Default uses the Pastel scheme.</value>
[Parameter]
public ColorScheme ColorScheme { get; set; }
/// <summary>
/// Gets or sets the callback invoked when a user clicks on a data point or segment in a chart series.
/// Provides information about the clicked series, data item, and value in the event arguments.
/// </summary>
/// <value>The series click event callback.</value>
[Parameter]
public EventCallback<SeriesClickEventArgs> SeriesClick { get; set; }
/// <summary>
/// Gets or sets the callback invoked when a user clicks on a legend item.
/// Useful for implementing custom behaviors like toggling series visibility or filtering data.
/// </summary>
/// <value>The legend click event callback.</value>
[Parameter]
public EventCallback<LegendClickEventArgs> LegendClick { get; set; }
[Inject]
TooltipService? TooltipService { get; set; }
/// <summary>
/// Gets the runtime width of the chart.
/// </summary>
protected double? Width { get; set; }
/// <summary>
/// Gets the runtime height of the chart.
/// </summary>
protected double? Height { get; set; }
/// <summary>
/// Gets or sets the top margin of the plot area.
/// </summary>
protected double MarginTop { get; set; }
/// <summary>
/// Gets or sets the left margin of the plot area.
/// </summary>
protected double MarginLeft { get; set; }
/// <summary>
/// Gets or sets the right margin of the plot area.
/// </summary>
protected double MarginRight { get; set; }
/// <summary>
/// Gets or sets the bottom margin of the plot area.
/// </summary>
protected double MarginBottom { get; set; }
/// <summary>
/// Gets or sets the child content. Used to specify series and other configuration.
/// </summary>
/// <value>The child content.</value>
[Parameter]
public RenderFragment? ChildContent { get; set; }
internal ScaleBase CategoryScale { get; set; } = new LinearScale();
internal ScaleBase ValueScale { get; set; } = new LinearScale();
internal IList<IChartSeries> Series { get; set; } = new List<IChartSeries>();
internal RadzenColumnOptions ColumnOptions { get; set; } = new RadzenColumnOptions();
internal RadzenBarOptions BarOptions { get; set; } = new RadzenBarOptions();
internal RadzenLegend Legend { get; set; } = new RadzenLegend();
internal RadzenCategoryAxis CategoryAxis { get; set; } = new RadzenCategoryAxis();
internal RadzenValueAxis ValueAxis { get; set; } = new RadzenValueAxis();
internal RadzenChartTooltipOptions Tooltip { get; set; } = new RadzenChartTooltipOptions();
internal void AddSeries(IChartSeries series)
{
if (!Series.Contains(series))
{
Series.Add(series);
}
}
internal void RemoveSeries(IChartSeries series)
{
Series.Remove(series);
}
/// <summary>
/// Returns the Series used by the Chart.
/// </summary>
/// <returns></returns>
public IReadOnlyList<IChartSeries> GetSeries() => Series.ToList();
/// <summary>
/// Returns whether the chart should render axes.
/// </summary>
/// <returns></returns>
protected bool ShouldRenderAxes()
{
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 || type == pyramidType;
});
}
internal bool ShouldInvertAxes()
{
return Series.Count > 0 && Series.All(series => series is IChartBarSeries);
}
/// <summary>
/// Updates the scales based on the configuration.
/// </summary>
/// <returns></returns>
protected virtual bool UpdateScales()
{
var valueScale = ValueScale;
var categoryScale = CategoryScale;
CategoryScale = new LinearScale { Output = CategoryScale.Output };
ValueScale = new LinearScale { Output = ValueScale.Output };
var visibleSeries = Series.Where(series => series.Visible).ToList();
var invisibleSeries = Series.Where(series => series.Visible == false).ToList();
if (visibleSeries.Count == 0 && invisibleSeries.Count > 0)
{
visibleSeries.Add(invisibleSeries.Last());
}
foreach (var series in visibleSeries)
{
CategoryScale = series.TransformCategoryScale(CategoryScale);
ValueScale = series.TransformValueScale(ValueScale);
}
AxisBase xAxis = CategoryAxis;
AxisBase yAxis = ValueAxis;
if (ShouldInvertAxes())
{
xAxis = ValueAxis;
yAxis = CategoryAxis;
}
else
{
CategoryScale.Padding = CategoryAxis.Padding;
}
CategoryScale.Resize(xAxis.Min!, xAxis.Max!);
if (xAxis.Step != null)
{
CategoryScale.Step = xAxis.Step;
CategoryScale.Round = false;
}
ValueScale.Resize(yAxis.Min!, yAxis.Max!);
if (yAxis.Step != null)
{
ValueScale.Step = yAxis.Step;
ValueScale.Round = false;
}
var legendSize = Legend.Measure(this);
var valueAxisSize = ValueAxis.Measure(this);
var categoryAxisSize = CategoryAxis.Measure(this);
if (!ShouldRenderAxes())
{
valueAxisSize = categoryAxisSize = 0;
}
MarginTop = MarginRight = 32;
MarginLeft = valueAxisSize;
MarginBottom = Math.Max(32, categoryAxisSize);
if (Legend.Visible)
{
if (Legend.Position == LegendPosition.Right || Legend.Position == LegendPosition.Left)
{
if (Legend.Position == LegendPosition.Right)
{
MarginRight = legendSize + 16;
}
else
{
MarginLeft = legendSize + 16 + valueAxisSize;
}
}
else if (Legend.Position == LegendPosition.Top || Legend.Position == LegendPosition.Bottom)
{
if (Legend.Position == LegendPosition.Top)
{
MarginTop = legendSize + 16;
}
else
{
MarginBottom = legendSize + 16 + categoryAxisSize;
}
}
}
CategoryScale.Output = new ScaleRange { Start = MarginLeft, End = Width != null ? Width.Value - MarginRight : 0 };
ValueScale.Output = new ScaleRange { Start = Height != null ? Height.Value - MarginBottom : 0, End = MarginTop };
ValueScale.Fit(ValueAxis.TickDistance);
CategoryScale.Fit(CategoryAxis.TickDistance);
var stateHasChanged = !ValueScale.IsEqualTo(valueScale);
if (!CategoryScale.IsEqualTo(categoryScale))
{
stateHasChanged = true;
}
return stateHasChanged;
}
/// <summary>
/// Invoked via interop when the RadzenChart resizes. Display the series with the new dimensions.
/// </summary>
/// <param name="width">The width.</param>
/// <param name="height">The height.</param>
[JSInvokable]
public async Task Resize(double width, double height)
{
var stateHasChanged = false;
if (width != Width)
{
Width = width;
stateHasChanged = true;
CategoryScale.Output = new ScaleRange { Start = MarginLeft, End = Width.Value - MarginRight };
}
if (height != Height)
{
Height = height;
stateHasChanged = true;
ValueScale.Output = new ScaleRange { Start = Height.Value - MarginBottom, End = MarginTop };
}
if (stateHasChanged)
{
await Refresh();
}
}
RenderFragment? tooltip;
object? tooltipData;
double mouseX;
double mouseY;
/// <summary>
/// Invoked via interop when the user moves the mouse over the RadzenChart. Displays the tooltip.
/// </summary>
/// <param name="x">The x.</param>
/// <param name="y">The y.</param>
[JSInvokable]
public async Task MouseMove(double x, double y)
{
mouseX = x;
mouseY = y;
await DisplayTooltip();
}
/// <summary>
/// The minimum pixel distance from a data point to the mouse cursor required for the SeriesClick event to fire. Set to 25 by default.
/// </summary>
[Parameter]
public int ClickTolerance { get; set; } = 25;
/// <summary>
/// The minimum pixel distance from a data point to the mouse cursor required by the tooltip to show. Set to 25 by default.
/// </summary>
[Parameter]
public int TooltipTolerance { get; set; } = 25;
/// <summary>
/// Invoked via interop when the user clicks the RadzenChart. Raises the <see cref="SeriesClick" /> handler.
/// </summary>
/// <param name="x">The x.</param>
/// <param name="y">The y.</param>
[JSInvokable]
public async Task Click(double x, double y)
{
IChartSeries? closestSeries = null;
object? closestSeriesData = null;
double closestSeriesDistanceSquared = ClickTolerance * ClickTolerance;
var queryX = x - MarginLeft;
var queryY = y - MarginTop;
foreach (var series in Series)
{
if (series.Visible)
{
var (seriesData, seriesDataPoint) = series.DataAt(queryX, queryY);
if (seriesData != null)
{
double xDelta = queryX - seriesDataPoint.X;
double yDelta = queryY - seriesDataPoint.Y;
double squaredDistance = xDelta * xDelta + yDelta * yDelta;
if (squaredDistance < closestSeriesDistanceSquared)
{
closestSeries = series;
closestSeriesData = seriesData;
closestSeriesDistanceSquared = squaredDistance;
}
}
}
}
if (closestSeriesData != null && closestSeries != null)
{
await closestSeries.InvokeClick(SeriesClick, closestSeriesData);
}
}
internal async Task DisplayTooltip()
{
if (Tooltip.Visible)
{
var orderedSeries = Series.OrderBy(s => s.RenderingOrder).Reverse();
IChartSeries? closestSeries = null;
object? closestSeriesData = null;
double closestSeriesDistanceSquared = TooltipTolerance * TooltipTolerance;
var queryX = mouseX - MarginLeft;
var queryY = mouseY - MarginTop;
foreach (var series in orderedSeries)
{
if (series.Visible)
{
foreach (var overlay in series.Overlays.Reverse())
{
if (overlay.Visible && overlay.Contains(queryX, queryY, TooltipTolerance))
{
tooltipData = null;
tooltip = overlay.RenderTooltip(queryX, queryY);
var tooltipPosition = overlay.GetTooltipPosition(queryX, queryY);
TooltipService?.OpenChartTooltip(Element, tooltipPosition.X + MarginLeft, tooltipPosition.Y + MarginTop, _ => tooltip, new ChartTooltipOptions
{
ColorScheme = ColorScheme
});
await Task.Yield();
return;
}
}
var (seriesData, seriesDataPoint) = series.DataAt(queryX, queryY);
if (seriesData != null)
{
double xDelta = queryX - seriesDataPoint.X;
double yDelta = queryY - seriesDataPoint.Y;
double squaredDistance = xDelta * xDelta + yDelta * yDelta;
if (squaredDistance < closestSeriesDistanceSquared)
{
closestSeries = series;
closestSeriesData = seriesData;
closestSeriesDistanceSquared = squaredDistance;
}
}
}
}
if (closestSeriesData != null && closestSeries != null)
{
if (closestSeriesData != tooltipData)
{
tooltipData = closestSeriesData;
tooltip = closestSeries.RenderTooltip(closestSeriesData);
var tooltipPosition = closestSeries.GetTooltipPosition(closestSeriesData);
TooltipService?.OpenChartTooltip(Element, tooltipPosition.X + MarginLeft, tooltipPosition.Y + MarginTop, _ => tooltip, new ChartTooltipOptions
{
ColorScheme = ColorScheme
});
await Task.Yield();
}
return;
}
}
if (tooltip != null)
{
tooltipData = null;
tooltip = null;
TooltipService?.Close();
await Task.Yield();
}
}
/// <summary>
/// Displays a Tooltip on a chart without user interaction, given a series, and the data associated with it.
/// </summary>
/// <param name="series"></param>
/// <param name="data"></param>
/// <exception cref="ArgumentException"></exception>
public async Task DisplayTooltipFor(IChartSeries series, object data)
{
ArgumentNullException.ThrowIfNull(series);
if (!Series.Contains(series))
{
throw new ArgumentException($"Series:{series.GetTitle()} does not exist in {nameof(this.Series)}");
}
if (IsJSRuntimeAvailable)
{
var point = series.GetTooltipPosition(data);
await MouseMove(point.X + MarginLeft, point.Y + MarginTop);
}
}
internal async Task ShowTooltip(IChartSeries series, object data)
{
if (IsJSRuntimeAvailable)
{
tooltipData = data;
tooltip = series.RenderTooltip(data);
var point = series.GetTooltipPosition(data);
TooltipService?.OpenChartTooltip(Element, point.X + MarginLeft, point.Y + MarginTop, _ => tooltip, new ChartTooltipOptions
{
ColorScheme = ColorScheme
});
await Task.Yield();
}
}
private bool widthAndHeightAreSet;
private bool firstRender = true;
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
this.firstRender = firstRender;
if (firstRender || visibleChanged)
{
visibleChanged = false;
if (Visible && JSRuntime != null)
{
var rect = await JSRuntime.InvokeAsync<Rect>("Radzen.createChart", Element, Reference);
if (!widthAndHeightAreSet)
{
widthAndHeightAreSet = true;
await Resize(rect.Width, rect.Height);
}
}
}
}
internal string? ClipPath { get; set; }
/// <inheritdoc />
protected override void OnInitialized()
{
base.OnInitialized();
ClipPath = $"clipPath{UniqueID}";
CategoryAxis.Chart = this;
ValueAxis.Chart = this;
Initialize();
}
private void Initialize()
{
double width = 0;
double height = 0;
if (CurrentStyle.TryGetValue("height", out var pixelHeight))
{
if (pixelHeight.EndsWith("px", StringComparison.Ordinal))
{
height = Convert.ToDouble(pixelHeight.TrimEnd("px".ToCharArray()), CultureInfo.InvariantCulture);
}
}
if (CurrentStyle.TryGetValue("width", out var pixelWidth))
{
if (pixelWidth.EndsWith("px", StringComparison.Ordinal))
{
width = Convert.ToDouble(pixelWidth.TrimEnd("px".ToCharArray()), CultureInfo.InvariantCulture);
}
}
if (width > 0 && height > 0)
{
widthAndHeightAreSet = true;
Width = width;
Height = height;
CategoryScale.Output = new ScaleRange { Start = MarginLeft, End = Width.Value - MarginRight };
ValueScale.Output = new ScaleRange { Start = Height.Value - MarginBottom, End = MarginTop };
}
}
private bool visibleChanged;
/// <inheritdoc />
public override async Task SetParametersAsync(ParameterView parameters)
{
bool shouldRefresh = parameters.DidParameterChange(nameof(Style), Style);
visibleChanged = parameters.DidParameterChange(nameof(Visible), Visible);
await base.SetParametersAsync(parameters);
if (shouldRefresh)
{
Initialize();
}
if (visibleChanged && !firstRender)
{
if (Visible == false && JSRuntime != null)
{
await JSRuntime.InvokeVoidAsync("Radzen.destroyChart", Element);
}
}
}
internal async Task Refresh(bool force = true)
{
if (widthAndHeightAreSet)
{
var stateHasChanged = UpdateScales();
if (stateHasChanged || force)
{
StateHasChanged();
await DisplayTooltip();
}
}
}
/// <summary>
/// Causes all series to refresh. Use it when <see cref="CartesianSeries{TItem}.Data" /> has changed.
/// </summary>
public async Task Reload()
{
await Refresh(true);
}
/// <inheritdoc />
public override void Dispose()
{
base.Dispose();
if (Visible && IsJSRuntimeAvailable && JSRuntime != null)
{
JSRuntime.InvokeVoid("Radzen.destroyChart", Element);
}
GC.SuppressFinalize(this);
}
/// <inheritdoc />
protected override string GetComponentCssClass()
{
return $"rz-chart rz-scheme-{ColorScheme.ToString().ToLowerInvariant()}";
}
}
}