using Microsoft.JSInterop;
using Radzen.Blazor.Rendering;
using Microsoft.AspNetCore.Components;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Threading.Tasks;
using System.Linq;
namespace Radzen.Blazor
{
///
/// 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.
///
///
/// Basic column chart:
///
/// <RadzenChart>
/// <RadzenColumnSeries Data=@revenue CategoryProperty="Quarter" Title="Revenue" ValueProperty="Revenue" />
/// </RadzenChart>
/// @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 }
/// };
/// }
///
/// Chart with multiple series and custom legend:
///
/// <RadzenChart>
/// <RadzenLineSeries Data=@sales2023 CategoryProperty="Month" ValueProperty="Amount" Title="2023 Sales" />
/// <RadzenLineSeries Data=@sales2024 CategoryProperty="Month" ValueProperty="Amount" Title="2024 Sales" />
/// <RadzenLegend Position="LegendPosition.Bottom" />
/// <RadzenCategoryAxis Formatter="@(value => value.ToString())" />
/// <RadzenValueAxis Formatter="@(value => value.ToString("C"))" />
/// </RadzenChart>
///
///
public partial class RadzenChart : RadzenComponent
{
///
/// 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.
///
/// The color scheme. Default uses the Pastel scheme.
[Parameter]
public ColorScheme ColorScheme { get; set; }
///
/// Gets or sets a value indicating whether series highlight on hover is enabled.
/// When true, hovering over a series or its legend item highlights the series and dims the others.
///
/// true if series hover is allowed; otherwise, false. Default is true.
[Parameter]
public bool AllowSeriesHover { get; set; } = true;
///
/// Gets or sets the minimum interval in milliseconds between mouse move notifications which drive the tooltip, crosshair and hover tracking.
/// Mouse moves are coalesced to animation frames; this value imposes an additional delay between dispatches.
/// Defaults to 0 (every animation frame) on WebAssembly and 50 on Blazor Server to limit SignalR traffic.
///
/// The mouse move throttle in milliseconds.
[Parameter]
public int? MouseMoveThrottle { get; set; }
///
/// Gets or sets a value indicating whether series animate when the chart first renders.
/// Series are revealed with a left-to-right wipe. The animation respects the user's reduced motion preference.
///
/// true if the initial render is animated; otherwise, false. Default is false.
[Parameter]
public bool Animate { get; set; }
///
/// Gets or sets the duration of the initial render animation in milliseconds.
///
/// The animation duration in milliseconds. Default is 700.
[Parameter]
public double AnimationDuration { get; set; } = 700;
///
/// Gets or sets a value indicating whether line and area series morph smoothly when their data changes.
/// Best suited for live dashboards where values update in place. Not applied while zooming or panning.
/// Supported in Chromium and Firefox; other browsers update instantly.
///
/// true to animate data updates; otherwise, false. Default is false.
[Parameter]
public bool AnimateDataUpdates { get; set; }
///
/// Gets or sets the synchronization group of the chart. Charts which share the same group display
/// a synchronized crosshair and active data points: hovering one chart highlights the same category in the others.
/// Charts in a group should plot the same kind of category (e.g. the same dates).
///
/// The synchronization group. Default is null (not synchronized).
[Parameter]
public string? SyncGroup { get; set; }
private static readonly object syncGroupsLock = new object();
private static readonly Dictionary> syncGroups = new Dictionary>();
private string? registeredSyncGroup;
internal double? SyncedPlotX { get; private set; }
private void RegisterSyncGroup()
{
if (registeredSyncGroup == SyncGroup)
{
return;
}
UnregisterSyncGroup();
if (SyncGroup != null)
{
lock (syncGroupsLock)
{
if (!syncGroups.TryGetValue(SyncGroup, out var charts))
{
charts = new List();
syncGroups[SyncGroup] = charts;
}
charts.Add(this);
}
registeredSyncGroup = SyncGroup;
}
}
private void UnregisterSyncGroup()
{
if (registeredSyncGroup == null)
{
return;
}
lock (syncGroupsLock)
{
if (syncGroups.TryGetValue(registeredSyncGroup, out var charts))
{
charts.Remove(this);
if (charts.Count == 0)
{
syncGroups.Remove(registeredSyncGroup);
}
}
}
registeredSyncGroup = null;
}
private void BroadcastSyncedHover(double x, double y)
{
if (registeredSyncGroup == null)
{
return;
}
List targets;
lock (syncGroupsLock)
{
targets = syncGroups.TryGetValue(registeredSyncGroup, out var charts)
? charts.Where(chart => !ReferenceEquals(chart, this)).ToList()
: new List();
}
double? categoryValue = null;
if ((x >= 0 || y >= 0) && Width.HasValue && Height.HasValue)
{
var plotWidth = Width.Value - MarginLeft - MarginRight;
var plotHeight = Height.Value - MarginTop - MarginBottom;
var queryX = x - MarginLeft;
var queryY = y - MarginTop;
if (queryX >= 0 && queryX <= plotWidth && queryY >= 0 && queryY <= plotHeight)
{
categoryValue = PixelToValue(CategoryScale, queryX);
}
}
foreach (var target in targets)
{
target.SetSyncedHover(categoryValue);
}
}
private void SetSyncedHover(double? categoryValue)
{
double? plotX = null;
if (categoryValue != null)
{
var x = CategoryScale.Scale(categoryValue.Value, true);
if (x >= 0 && x <= CategoryScale.OutputSize)
{
plotX = x;
}
}
if (SyncedPlotX != plotX)
{
SyncedPlotX = plotX;
HoverOverlay?.Refresh();
TooltipOverlay?.Refresh();
}
}
internal string? GetChartStyle()
{
return Animate ? $"--rz-chart-animation-duration: {AnimationDuration.ToInvariantString()}ms;{Style}" : Style;
}
///
/// Gets whether the chart is currently rendered in a right-to-left context.
/// When true, the category axis direction is reversed and the value axis renders on the right side.
///
internal bool IsRTL { get; private set; }
///
/// Called from JavaScript when the document direction changes.
///
[JSInvokable]
public async Task SetRTL(bool isRTL)
{
if (IsRTL != isRTL)
{
IsRTL = isRTL;
await Refresh();
}
}
///
/// Resolves the legend position to a concrete side, mapping the direction-aware
/// / values to
/// / based on .
///
internal LegendPosition EffectiveLegendPosition => (Legend?.Position ?? LegendPosition.Right) switch
{
LegendPosition.Start => IsRTL ? LegendPosition.Right : LegendPosition.Left,
LegendPosition.End => IsRTL ? LegendPosition.Left : LegendPosition.Right,
var position => position
};
///
/// 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.
///
/// The series click event callback.
[Parameter]
public EventCallback SeriesClick { get; set; }
///
/// 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.
///
/// The legend click event callback.
[Parameter]
public EventCallback LegendClick { get; set; }
[Inject]
TooltipService? TooltipService { get; set; }
///
/// Gets the runtime width of the chart.
///
protected double? Width { get; set; }
///
/// Gets the runtime width of the chart, exposed for legend row-wrap measurement.
///
internal double? MeasuredWidth => Width;
///
/// Gets the runtime height of the chart.
///
protected double? Height { get; set; }
///
/// Gets or sets the top margin of the plot area.
///
protected double MarginTop { get; set; }
///
/// Gets or sets the left margin of the plot area.
///
protected double MarginLeft { get; set; }
///
/// Gets or sets the right margin of the plot area.
///
protected double MarginRight { get; set; }
///
/// Gets or sets the bottom margin of the plot area.
///
protected double MarginBottom { get; set; }
///
/// Gets or sets the child content. Used to specify series and other configuration.
///
/// The child content.
[Parameter]
public RenderFragment? ChildContent { get; set; }
internal ScaleBase CategoryScale { get; set; } = new LinearScale();
internal ScaleBase ValueScale { get; set; } = new LinearScale();
internal Dictionary AdditionalValueScales { get; set; } = new Dictionary();
internal IList Series { get; set; } = new List();
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 Dictionary AdditionalValueAxes { get; set; } = new Dictionary();
internal RadzenChartTooltipOptions Tooltip { get; set; } = new RadzenChartTooltipOptions();
///
/// Gets or sets whether mouse wheel zoom is enabled.
///
[Parameter]
public bool AllowZoom { get; set; }
///
/// Gets or sets whether pan via scrollbar is enabled.
///
[Parameter]
public bool AllowPan { get; set; }
///
/// Gets or sets the zoom level as a percentage. A value of 100 means no zoom (full range visible).
/// Higher values zoom in (e.g., 200 shows half the range, 400 shows a quarter).
/// Set to 100 to reset zoom. Supports two-way binding with @bind-Zoom.
///
[Parameter]
public double Zoom { get; set; } = 100;
///
/// Gets or sets the callback invoked when the zoom level changes due to user interaction (mouse wheel or pan).
/// Used for two-way binding with @bind-Zoom.
///
[Parameter]
public EventCallback ZoomChanged { get; set; }
///
/// Gets or sets the callback invoked when the visible range changes due to zoom or pan.
/// Provides the current zoom level and visible range fractions.
///
[Parameter]
public EventCallback ViewChange { get; set; }
///
/// Gets or sets the start of the visible range as a fraction (0-1) of the full category range.
/// Supports two-way binding with @bind-ViewStart.
///
[Parameter]
public double ViewStart { get; set; }
///
/// Gets or sets the callback invoked when the visible range start changes due to user interaction.
/// Used for two-way binding with @bind-ViewStart.
///
[Parameter]
public EventCallback ViewStartChanged { get; set; }
///
/// Gets or sets the end of the visible range as a fraction (0-1) of the full category range.
/// Supports two-way binding with @bind-ViewEnd.
///
[Parameter]
public double ViewEnd { get; set; } = 1;
///
/// Gets or sets the callback invoked when the visible range end changes due to user interaction.
/// Used for two-way binding with @bind-ViewEnd.
///
[Parameter]
public EventCallback ViewEndChanged { get; set; }
///
/// Gets or sets the start of the visible range as a fraction (0-1) of the full category range.
///
internal double ZoomStart { get; set; }
///
/// Gets or sets the end of the visible range as a fraction (0-1) of the full category range.
///
internal double ZoomEnd { get; set; } = 1;
///
/// True while the chart itself is processing a zoom/pan operation.
/// Prevents SetParametersAsync from overwriting ZoomStart/ZoomEnd
/// with stale values from the parent during sequential callbacks.
///
private bool isInternalZoom;
///
/// The full category scale input range before zoom is applied.
///
private double fullCategoryStart;
private double fullCategoryEnd;
private void ApplyZoomLevel()
{
var zoomLevel = Math.Max(100, Zoom) / 100.0;
var range = 1.0 / zoomLevel;
var center = (ZoomStart + ZoomEnd) / 2;
ZoomStart = Math.Max(0, center - range / 2);
ZoomEnd = Math.Min(1, ZoomStart + range);
if (ZoomStart < 0) { ZoomStart = 0; ZoomEnd = range; }
}
private async Task NotifyZoomChanged()
{
var range = ZoomEnd - ZoomStart;
var newZoom = range > 0 ? Math.Round(100.0 / range) : 100;
Zoom = newZoom;
ViewStart = ZoomStart;
ViewEnd = ZoomEnd;
await ZoomChanged.InvokeAsync(newZoom);
await ViewStartChanged.InvokeAsync(ZoomStart);
await ViewEndChanged.InvokeAsync(ZoomEnd);
await ViewChange.InvokeAsync(new ChartViewChangeEventArgs
{
Zoom = newZoom,
ViewStart = ZoomStart,
ViewEnd = ZoomEnd
});
}
///
/// The bottom offset for the scrollbar, to position it above the legend.
///
private double scrollbarBottom;
internal void AddValueAxis(string name, RadzenValueAxis axis)
{
AdditionalValueAxes[name] = axis;
}
internal void RemoveValueAxis(string name)
{
AdditionalValueAxes.Remove(name);
AdditionalValueScales.Remove(name);
}
internal ScaleBase GetValueScale(string? name)
{
if (string.IsNullOrEmpty(name))
{
return ValueScale;
}
return AdditionalValueScales.TryGetValue(name, out var scale) ? scale : ValueScale;
}
internal RadzenValueAxis GetValueAxis(string? name)
{
if (string.IsNullOrEmpty(name))
{
return ValueAxis;
}
return AdditionalValueAxes.TryGetValue(name, out var axis) ? axis : ValueAxis;
}
internal ScaleBase GetInvertedValueScale(string? name)
{
if (string.IsNullOrEmpty(name))
{
return CategoryScale;
}
return AdditionalValueScales.TryGetValue(name, out var scale) ? scale : CategoryScale;
}
internal void AddSeries(IChartSeries series)
{
if (!Series.Contains(series))
{
Series.Add(series);
}
}
internal void RemoveSeries(IChartSeries series)
{
if (Series.Remove(series))
{
_ = Refresh(false);
}
}
///
/// Returns the Series used by the Chart.
///
///
public IReadOnlyList GetSeries() => Series.ToList();
///
/// Returns whether the chart should render axes.
///
///
protected bool ShouldRenderAxes()
{
return !Series.All(IsPolarSeries);
}
// Pie, donut, funnel and pyramid series are radial - they have no value/category axes.
// Walk the base chain so user subclasses (e.g. class MyPie : RadzenPieSeries) are matched too.
private static bool IsPolarSeries(IChartSeries series)
{
for (var type = series.GetType(); type != null; type = type.BaseType)
{
if (!type.IsGenericType)
{
continue;
}
var definition = type.GetGenericTypeDefinition();
if (definition == typeof(RadzenPieSeries<>)
|| definition == typeof(RadzenDonutSeries<>)
|| definition == typeof(RadzenFunnelSeries<>)
|| definition == typeof(RadzenPyramidSeries<>))
{
return true;
}
}
return false;
}
internal bool ShouldInvertAxes()
{
return Series.Count > 0 && Series.All(series => series is IChartBarSeries);
}
///
/// Updates the scales based on the configuration.
///
///
protected virtual bool UpdateScales()
{
var valueScale = ValueScale;
var categoryScale = CategoryScale;
CategoryScale = new LinearScale { Output = CategoryScale.Output };
ValueScale = ValueAxis.Logarithmic
? new LogarithmicScale { Base = ValueAxis.LogarithmicBase, Output = ValueScale.Output }
: 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());
}
var invertAxes = ShouldInvertAxes();
// Partition series: primary axis vs named axes
var primarySeries = new List();
var namedAxisSeries = new Dictionary>();
foreach (var series in visibleSeries)
{
var axisName = (series is IChartValueAxisSeries named) ? named.ValueAxisName : null;
if (string.IsNullOrEmpty(axisName))
{
primarySeries.Add(series);
}
else
{
if (!namedAxisSeries.TryGetValue(axisName, out var list))
{
list = new List();
namedAxisSeries[axisName] = list;
}
list.Add(series);
}
}
foreach (var series in invisibleSeries)
{
var axisName = (series is IChartValueAxisSeries named) ? named.ValueAxisName : null;
if (!string.IsNullOrEmpty(axisName) && !namedAxisSeries.ContainsKey(axisName))
{
namedAxisSeries[axisName] = new List { series };
}
}
// All series contribute to the shared category scale (bar-family series swap their transforms when axes are inverted)
foreach (var series in visibleSeries)
{
if (invertAxes)
{
ValueScale = series.TransformValueScale(ValueScale);
}
else
{
CategoryScale = series.TransformCategoryScale(CategoryScale);
}
}
// Primary series contribute to primary value scale
foreach (var series in primarySeries)
{
if (invertAxes)
{
CategoryScale = series.TransformCategoryScale(CategoryScale);
}
else
{
ValueScale = series.TransformValueScale(ValueScale);
}
}
// Named-axis series contribute to their own scales
foreach (var entry in namedAxisSeries)
{
var axisName = entry.Key;
var axis = GetValueAxis(axisName);
ScaleBase CreateAdditionalScale(ScaleRange? output = null)
{
if (axis.Logarithmic)
{
var s = new LogarithmicScale { Base = axis.LogarithmicBase };
if (output != null)
{
s.Output = output;
}
return s;
}
var ls = new LinearScale();
if (output != null)
{
ls.Output = output;
}
return ls;
}
ScaleBase additionalScale;
if (!AdditionalValueScales.TryGetValue(axisName, out var existingScale))
{
additionalScale = CreateAdditionalScale();
}
else
{
additionalScale = CreateAdditionalScale(existingScale.Output);
}
foreach (var series in entry.Value)
{
additionalScale = invertAxes ? series.TransformCategoryScale(additionalScale) : series.TransformValueScale(additionalScale);
}
AdditionalValueScales[axisName] = additionalScale;
}
// Remove scales for axes that no longer have series
foreach (var key in AdditionalValueScales.Keys.ToList())
{
if (!namedAxisSeries.ContainsKey(key))
{
AdditionalValueScales.Remove(key);
}
}
AxisBase xAxis = CategoryAxis;
AxisBase yAxis = ValueAxis;
if (invertAxes)
{
xAxis = ValueAxis;
yAxis = CategoryAxis;
}
else
{
CategoryScale.Padding = CategoryAxis.Padding;
}
// The ordinal (category) scale is the CategoryScale normally, but bar-family series and
// bullet swap the axes so it becomes the ValueScale. Apply the placement to whichever it is,
// for both orientations.
var ordinalScale = (CategoryScale as OrdinalScale) ?? (ValueScale as OrdinalScale);
if (ordinalScale != null)
{
ordinalScale.Placement = CategoryAxis.TickPlacement;
}
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;
}
// Resize additional value scales
foreach (var entry in AdditionalValueScales)
{
var axis = GetValueAxis(entry.Key);
entry.Value.Resize(axis.Min!, axis.Max!);
if (axis.Step != null)
{
entry.Value.Step = axis.Step;
entry.Value.Round = false;
}
}
var legendSize = Legend.Measure(this);
var valueAxisSize = ValueAxis.Measure(this);
var categoryAxisSize = CategoryAxis.Measure(this);
// Measure additional axes for the right margin (top margin when axes are inverted)
double additionalAxesWidth = 0;
double additionalAxesHeight = 0;
foreach (var entry in AdditionalValueAxes)
{
if (invertAxes)
{
additionalAxesHeight += entry.Value.MeasureHorizontal(GetValueScale(entry.Key));
}
else
{
additionalAxesWidth += entry.Value.Measure(this, GetValueScale(entry.Key));
}
}
if (!ShouldRenderAxes())
{
valueAxisSize = categoryAxisSize = 0;
additionalAxesWidth = 0;
additionalAxesHeight = 0;
}
MarginTop = 32 + additionalAxesHeight;
if (IsRTL)
{
MarginRight = valueAxisSize;
MarginLeft = 32 + additionalAxesWidth;
}
else
{
MarginRight = 32 + additionalAxesWidth;
MarginLeft = valueAxisSize;
}
MarginBottom = Math.Max(32, categoryAxisSize);
if (Legend.Visible)
{
var legendPosition = EffectiveLegendPosition;
if (legendPosition == LegendPosition.Right || legendPosition == LegendPosition.Left)
{
if (legendPosition == LegendPosition.Right)
{
MarginRight = legendSize + 16 + (IsRTL ? valueAxisSize : additionalAxesWidth);
}
else
{
MarginLeft = legendSize + 16 + (IsRTL ? additionalAxesWidth : valueAxisSize);
}
}
else if (legendPosition == LegendPosition.Top || legendPosition == LegendPosition.Bottom)
{
if (legendPosition == LegendPosition.Top)
{
MarginTop = legendSize + 16 + additionalAxesHeight;
}
else
{
MarginBottom = legendSize + 16 + categoryAxisSize;
}
}
}
if (AllowZoom || AllowPan)
{
MarginBottom += 20;
scrollbarBottom = 0;
if (Legend.Visible && Legend.Position == LegendPosition.Bottom)
{
scrollbarBottom = legendSize + 16;
}
}
var categoryStart = MarginLeft;
var categoryEnd = Width != null ? Width.Value - MarginRight : 0;
var valueStart = Height != null ? Height.Value - MarginBottom : 0;
var valueEnd = MarginTop;
var categoryReversed = CategoryAxis.Inverted != IsRTL;
var valueReversed = ValueAxis.Inverted;
CategoryScale.Output = new ScaleRange
{
Start = categoryReversed ? categoryEnd : categoryStart,
End = categoryReversed ? categoryStart : categoryEnd
};
ValueScale.Output = new ScaleRange
{
Start = valueReversed ? valueEnd : valueStart,
End = valueReversed ? valueStart : valueEnd
};
ValueScale.Fit(ValueAxis.TickDistance);
CategoryScale.Fit(CategoryAxis.TickDistance);
// Apply zoom to category scale
if ((AllowZoom || AllowPan) && (ZoomStart > 0 || ZoomEnd < 1))
{
fullCategoryStart = CategoryScale.Input.Start;
fullCategoryEnd = CategoryScale.Input.End;
var fullRange = fullCategoryEnd - fullCategoryStart;
CategoryScale.Input = new ScaleRange
{
Start = fullCategoryStart + fullRange * ZoomStart,
End = fullCategoryStart + fullRange * ZoomEnd
};
CategoryScale.Round = false;
// Recalculate step for zoomed range
var zoomedTicks = CategoryScale.Ticks(CategoryAxis.TickDistance);
CategoryScale.Step = zoomedTicks.Step;
}
else
{
fullCategoryStart = CategoryScale.Input.Start;
fullCategoryEnd = CategoryScale.Input.End;
}
// Set output ranges and fit additional scales
foreach (var entry in AdditionalValueScales)
{
var axis = GetValueAxis(entry.Key);
if (invertAxes)
{
var reversed = axis.Inverted != IsRTL;
entry.Value.Output = new ScaleRange
{
Start = reversed ? categoryEnd : categoryStart,
End = reversed ? categoryStart : categoryEnd
};
}
else
{
entry.Value.Output = new ScaleRange
{
Start = axis.Inverted ? valueEnd : valueStart,
End = axis.Inverted ? valueStart : valueEnd
};
}
entry.Value.Fit(axis.TickDistance);
}
var stateHasChanged = !ValueScale.IsEqualTo(valueScale);
if (!CategoryScale.IsEqualTo(categoryScale))
{
stateHasChanged = true;
}
return stateHasChanged;
}
///
/// Invoked via interop when the RadzenChart resizes. Display the series with the new dimensions.
///
/// The width.
/// The height.
[JSInvokable]
public async Task Resize(double width, double height)
{
var stateHasChanged = false;
if (width != Width)
{
Width = width;
stateHasChanged = true;
var cs = MarginLeft;
var ce = Width.Value - MarginRight;
var catReversed = CategoryAxis.Inverted != IsRTL;
CategoryScale.Output = new ScaleRange
{
Start = catReversed ? ce : cs,
End = catReversed ? cs : ce
};
if (ShouldInvertAxes())
{
foreach (var entry in AdditionalValueScales)
{
var axis = GetValueAxis(entry.Key);
var reversed = axis.Inverted != IsRTL;
entry.Value.Output = new ScaleRange
{
Start = reversed ? ce : cs,
End = reversed ? cs : ce
};
}
}
}
if (height != Height)
{
Height = height;
stateHasChanged = true;
var vs = Height.Value - MarginBottom;
var ve = MarginTop;
var valReversed = ValueAxis.Inverted;
ValueScale.Output = new ScaleRange
{
Start = valReversed ? ve : vs,
End = valReversed ? vs : ve
};
if (!ShouldInvertAxes())
{
foreach (var entry in AdditionalValueScales)
{
var axis = GetValueAxis(entry.Key);
entry.Value.Output = new ScaleRange
{
Start = axis.Inverted ? ve : vs,
End = axis.Inverted ? vs : ve
};
}
}
}
if (stateHasChanged)
{
await Refresh();
}
}
RenderFragment? tooltip;
object? tooltipData;
IChartSeries? tooltipSeries;
double mouseX;
double mouseY;
// Plot-area-local coordinates of the nearest data point under the cursor. Used by the
// crosshair overlay so the vertical line snaps to the category X of the closest series point.
internal double SnapPlotX { get; private set; } = -1;
internal double SnapPlotY { get; private set; } = -1;
// True while the cursor is over the chart plot area. Drives crosshair visibility.
internal bool MouseInside { get; private set; }
// The series whose data point is currently nearest to the cursor (within tooltip tolerance).
// Used to resolve which value axis owns the horizontal crosshair line in multi-axis charts.
internal IChartSeries? HoveredSeries { get; private set; }
internal List<(IChartSeries Series, object Data, Point Point)>? SharedPointsAtSnap { get; private set; }
// Nearest data-point X across all visible series, in plot-local pixels. Used by the X crosshair
// when Snap=true. CartesianSeries.DataAt iterates all items and picks the closest by X with no
// tolerance, so this works even when the cursor is far from any series.
private double? NearestDataPointX(double plotLocalCursorX, double plotLocalCursorY)
{
double bestDistance = double.MaxValue;
double? bestX = null;
foreach (var series in Series)
{
if (!series.Visible)
{
continue;
}
var (data, point) = series.DataAt(plotLocalCursorX, plotLocalCursorY);
if (data == null)
{
continue;
}
var distance = Math.Abs(point.X - plotLocalCursorX);
if (distance < bestDistance)
{
bestDistance = distance;
bestX = point.X;
}
}
return bestX;
}
private bool AnyAxisCrosshairVisible()
{
if (CategoryAxis?.Crosshair?.Visible == true)
{
return true;
}
if (ValueAxis?.Crosshair?.Visible == true)
{
return true;
}
foreach (var axis in AdditionalValueAxes.Values)
{
if (axis.Crosshair?.Visible == true)
{
return true;
}
}
return false;
}
///
/// Invoked via interop when the user moves the mouse over the RadzenChart. Displays the tooltip.
///
/// The x.
/// The y.
[JSInvokable]
public async Task MouseMove(double x, double y)
{
mouseX = x;
mouseY = y;
// JS sends (-1, -1) on mouseleave. Clear hover state and re-render to hide the crosshair.
if (x < 0 && y < 0)
{
if (MouseInside || SnapPlotX >= 0 || SnapPlotY >= 0)
{
MouseInside = false;
SnapPlotX = -1;
SnapPlotY = -1;
HoveredSeries = null;
SharedPointsAtSnap = null;
if (Tooltip != null && (Tooltip.Split || AxisTooltipTrigger))
{
TooltipOverlay?.Refresh();
}
if (AnyAxisCrosshairVisible() || ActivePointEnabled || (Tooltip != null && Tooltip.Split))
{
HoverOverlay?.Refresh();
}
}
}
else
{
MouseInside = true;
// The crosshair follows the cursor even when no data point is in tooltip range,
if (AnyAxisCrosshairVisible())
{
HoverOverlay?.Refresh();
}
}
BroadcastSyncedHover(x, y);
await DisplayTooltip();
}
///
/// The minimum pixel distance from a data point to the mouse cursor required for the SeriesClick event to fire. Set to 25 by default.
///
[Parameter]
public int ClickTolerance { get; set; } = 25;
///
/// The minimum pixel distance from a data point to the mouse cursor required by the tooltip to show. Set to 25 by default.
///
[Parameter]
public int TooltipTolerance { get; set; } = 25;
///
/// Invoked via interop when the user clicks the RadzenChart. Raises the handler.
///
/// The x.
/// The y.
[JSInvokable]
public async Task Click(double x, double y)
{
var queryX = x - MarginLeft;
var queryY = y - MarginTop;
var (closestSeries, closestSeriesData) = FindClosestSeries(queryX, queryY, ClickTolerance);
if (closestSeriesData != null && closestSeries != null)
{
await closestSeries.InvokeClick(SeriesClick, closestSeriesData);
}
}
///
/// Invoked via interop when the user scrolls the mouse wheel over the chart. Zooms in or out.
///
/// The mouse X position relative to the chart element.
/// Positive to zoom out, negative to zoom in.
[JSInvokable]
public async Task OnWheel(double x, int delta)
{
if (!AllowZoom)
{
return;
}
var plotWidth = (Width ?? 0) - MarginLeft - MarginRight;
if (plotWidth <= 0)
{
return;
}
var fraction = Math.Clamp((x - MarginLeft) / plotWidth, 0, 1);
var zoomFactor = delta < 0 ? 0.8 : 1.25;
var range = ZoomEnd - ZoomStart;
var newRange = Math.Clamp(range * zoomFactor, 0.01, 1.0);
var center = ZoomStart + fraction * range;
var newStart = center - newRange * fraction;
var newEnd = newStart + newRange;
if (newEnd > 1) { newEnd = 1; newStart = 1 - newRange; }
if (newStart < 0) { newStart = 0; newEnd = newRange; }
ZoomStart = Math.Max(0, newStart);
ZoomEnd = Math.Min(1, newEnd);
isInternalZoom = true;
try
{
await NotifyZoomChanged();
await Refresh();
}
finally
{
isInternalZoom = false;
}
}
///
/// Invoked via interop when the user drags the scrollbar thumb.
///
/// The new start position as a fraction (0-1).
[JSInvokable]
public async Task OnPan(double position)
{
if (!AllowPan && !AllowZoom)
{
return;
}
var range = ZoomEnd - ZoomStart;
ZoomStart = Math.Clamp(position, 0, 1 - range);
ZoomEnd = ZoomStart + range;
isInternalZoom = true;
try
{
await NotifyZoomChanged();
await Refresh();
}
finally
{
isInternalZoom = false;
}
}
// A column/bar/pie series reports a hit anywhere inside its filled region by returning the cursor
// position itself, so its distance is always 0 and it would always beat a line/scatter/bubble
// marker drawn on top of it. Give point series priority: pick the closest point series within
// tolerance first, and only fall back to region series when no point series qualifies.
internal (IChartSeries?, object?) FindClosestSeries(double queryX, double queryY, double tolerance)
{
var ordered = Series.OrderBy(s => s.RenderingOrder).Reverse().ToList();
var pointHit = ClosestSeries(ordered.Where(s => !IsRegionSeries(s)), queryX, queryY, tolerance);
if (pointHit.Item1 != null)
{
return pointHit;
}
return ClosestSeries(ordered.Where(IsRegionSeries), queryX, queryY, tolerance);
}
private static (IChartSeries?, object?) ClosestSeries(IEnumerable candidates, double queryX, double queryY, double tolerance)
{
IChartSeries? closestSeries = null;
object? closestData = null;
var closestDistanceSquared = tolerance * tolerance;
foreach (var series in candidates)
{
if (!series.Visible)
{
continue;
}
var (data, point) = series.DataAt(queryX, queryY);
if (data != null)
{
var xDelta = queryX - point.X;
var yDelta = queryY - point.Y;
var squaredDistance = xDelta * xDelta + yDelta * yDelta;
if (squaredDistance < closestDistanceSquared)
{
closestSeries = series;
closestData = data;
closestDistanceSquared = squaredDistance;
}
}
}
return (closestSeries, closestData);
}
internal bool AxisTooltipTrigger
{
get
{
return (Tooltip?.Trigger ?? ChartTooltipTrigger.Auto) switch
{
ChartTooltipTrigger.Axis => true,
ChartTooltipTrigger.Point => false,
_ => CategoryAxis?.Crosshair?.Visible == true || SyncGroup != null,
};
}
}
private (IChartSeries?, object?) ClosestSeriesByCategory(double queryX, double queryY)
{
IChartSeries? closestSeries = null;
object? closestData = null;
var bestX = double.MaxValue;
var bestY = double.MaxValue;
foreach (var series in Series.OrderBy(s => s.RenderingOrder).Reverse())
{
if (!series.Visible || IsRegionSeries(series))
{
continue;
}
var (data, point) = series.DataAt(queryX, queryY);
if (data == null)
{
continue;
}
var dx = Math.Abs(point.X - queryX);
var dy = Math.Abs(point.Y - queryY);
if (dx < bestX - 0.5 || (Math.Abs(dx - bestX) <= 0.5 && dy < bestY))
{
bestX = dx;
bestY = dy;
closestSeries = series;
closestData = data;
}
}
return (closestSeries, closestData);
}
// True for series that fill a region (columns, bars, stacks and the radial pie/donut/funnel/pyramid),
// i.e. whose DataAt returns the cursor itself rather than a discrete data point.
private static bool IsRegionSeries(IChartSeries series)
{
return series is IChartColumnSeries
|| series is IChartBarSeries
|| series is IChartStackedColumnSeries
|| series is IChartStackedBarSeries
|| series is IChartFullStackedColumnSeries
|| series is IChartFullStackedBarSeries
|| IsPolarSeries(series);
}
internal async Task DisplayTooltip()
{
if (Tooltip.Visible)
{
var queryX = mouseX - MarginLeft;
var queryY = mouseY - MarginTop;
foreach (var series in Series.OrderBy(s => s.RenderingOrder).Reverse())
{
if (!series.Visible)
{
continue;
}
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 axisMode = AxisTooltipTrigger;
var cursorInPlot = false;
if (Width.HasValue && Height.HasValue)
{
var plotWidth = Width.Value - MarginLeft - MarginRight;
var plotHeight = Height.Value - MarginTop - MarginBottom;
cursorInPlot = queryX >= 0 && queryX <= plotWidth && queryY >= 0 && queryY <= plotHeight;
}
IChartSeries? closestSeries = null;
object? closestSeriesData = null;
if (!axisMode || cursorInPlot)
{
(closestSeries, closestSeriesData) = FindClosestSeries(queryX, queryY, TooltipTolerance);
if (closestSeriesData == null && axisMode)
{
(closestSeries, closestSeriesData) = ClosestSeriesByCategory(queryX, queryY);
}
}
if (closestSeriesData != null && closestSeries != null)
{
var snap = closestSeries.GetTooltipPosition(closestSeriesData);
var snapChanged = SnapPlotX != snap.X || SnapPlotY != snap.Y || !ReferenceEquals(HoveredSeries, closestSeries);
SnapPlotX = snap.X;
SnapPlotY = snap.Y;
HoveredSeries = closestSeries;
if (Tooltip.Split || (Tooltip.Shared && ActivePointEnabled))
{
// Collect all visible series' nearest points at the snapped X so the split
// overlay can render a small tooltip per series anchored to its own Y.
var list = new List<(IChartSeries Series, object Data, Point Point)>();
foreach (var series in Series.OrderBy(s => s.RenderingOrder))
{
if (!series.Visible)
{
continue;
}
var (d, p) = series.DataAt(SnapPlotX, SnapPlotY);
if (d != null)
{
list.Add((series, d, series.GetTooltipPosition(d)));
}
}
SharedPointsAtSnap = list;
}
else
{
SharedPointsAtSnap = null;
}
if (closestSeriesData != tooltipData || !ReferenceEquals(closestSeries, tooltipSeries))
{
tooltipData = closestSeriesData;
tooltipSeries = closestSeries;
tooltip = closestSeries.RenderTooltip(closestSeriesData);
if (!Tooltip.Split && !axisMode)
{
TooltipService?.OpenChartTooltip(Element, snap.X + MarginLeft, snap.Y + MarginTop, _ => tooltip, new ChartTooltipOptions
{
ColorScheme = ColorScheme
});
}
await Task.Yield();
}
if (snapChanged)
{
if (Tooltip.Split || axisMode)
{
TooltipOverlay?.Refresh();
}
if (AnyAxisCrosshairVisible() || ActivePointEnabled || Tooltip.Split)
{
HoverOverlay?.Refresh();
}
}
return;
}
}
var cleared = false;
if (SnapPlotX >= 0 || SnapPlotY >= 0)
{
SnapPlotX = -1;
SnapPlotY = -1;
HoveredSeries = null;
SharedPointsAtSnap = null;
cleared = true;
if (AnyAxisCrosshairVisible() || ActivePointEnabled || Tooltip.Split)
{
HoverOverlay?.Refresh();
}
}
if (tooltip != null)
{
tooltipData = null;
tooltipSeries = null;
tooltip = null;
cleared = true;
TooltipService?.Close();
await Task.Yield();
}
if (cleared && (Tooltip.Split || AxisTooltipTrigger))
{
TooltipOverlay?.Refresh();
}
}
///
/// Displays a Tooltip on a chart without user interaction, given a series, and the data associated with it.
///
///
///
///
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;
tooltipSeries = series;
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;
///
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
this.firstRender = firstRender;
if (firstRender || visibleChanged)
{
visibleChanged = false;
if (Visible && JSRuntime != null)
{
var mouseMoveThrottle = MouseMoveThrottle ?? (JSRuntime is IJSInProcessRuntime ? 0 : 50);
var rect = await JSRuntime.InvokeAsync("Radzen.createChart", Element, Reference, mouseMoveThrottle);
if (!widthAndHeightAreSet)
{
widthAndHeightAreSet = true;
await Resize(rect.Width, rect.Height);
}
}
}
}
internal string? ClipPath { get; set; }
///
protected override void OnInitialized()
{
base.OnInitialized();
ClipPath = $"clipPath{UniqueID}";
CategoryAxis.Chart = this;
ValueAxis.Chart = this;
if (ViewStart != 0 || ViewEnd != 1)
{
ZoomStart = ViewStart;
ZoomEnd = ViewEnd;
}
else
{
ApplyZoomLevel();
}
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;
///
public override async Task SetParametersAsync(ParameterView parameters)
{
bool shouldRefresh = parameters.DidParameterChange(nameof(Style), Style);
bool zoomChanged = parameters.DidParameterChange(nameof(Zoom), Zoom);
bool viewStartChanged = parameters.DidParameterChange(nameof(ViewStart), ViewStart);
bool viewEndChanged = parameters.DidParameterChange(nameof(ViewEnd), ViewEnd);
visibleChanged = parameters.DidParameterChange(nameof(Visible), Visible);
await base.SetParametersAsync(parameters);
RegisterSyncGroup();
if ((viewStartChanged || viewEndChanged) && !isInternalZoom)
{
ZoomStart = ViewStart;
ZoomEnd = ViewEnd;
if (widthAndHeightAreSet)
{
UpdateScales();
}
}
else if (zoomChanged)
{
ApplyZoomLevel();
}
if (shouldRefresh)
{
Initialize();
}
if (visibleChanged && !firstRender)
{
if (Visible == false)
{
await JSRuntime!.InvokeVoidAsync("Radzen.disposeElement", Element);
}
}
}
internal async Task Refresh(bool force = true)
{
if (disposed)
{
return;
}
if (widthAndHeightAreSet)
{
var stateHasChanged = UpdateScales();
if (stateHasChanged || force)
{
StateHasChanged();
await DisplayTooltip();
}
}
}
///
/// Causes all series to refresh. Use it when has changed.
///
public async Task Reload()
{
await Refresh(true);
}
///
/// Resets zoom and pan to show the full data range.
///
public async Task ResetZoom()
{
ZoomStart = 0;
ZoomEnd = 1;
isInternalZoom = true;
try
{
await NotifyZoomChanged();
await Refresh(true);
}
finally
{
isInternalZoom = false;
}
}
///
/// Returns the SVG markup of the rendered chart as a string.
/// The plot area and axes are included, with theme styles inlined so the SVG renders standalone.
/// Legends and tooltips are HTML overlays and are not included.
/// To download it, pass the result to the Radzen.downloadFile JavaScript helper.
///
///
/// A representing the asynchronous operation. The task result contains the SVG markup of the chart.
///
public async Task ToSvg()
{
if (IsJSRuntimeAvailable && JSRuntime != null)
{
return await JSRuntime.InvokeAsync("Radzen.chartToSvg", Element);
}
return string.Empty;
}
///
/// Renders the chart as a PNG image and downloads it in the browser.
/// The plot area and axes are included; legends and tooltips are HTML overlays and are not included.
///
/// The download file name. Default is chart.png.
/// The PNG width in pixels. When omitted the rendered size scaled by the device pixel ratio is used.
/// The PNG height in pixels. When omitted it is derived from preserving the aspect ratio.
public async Task ToPng(string fileName = "chart.png", int? width = null, int? height = null)
{
ArgumentNullException.ThrowIfNull(fileName);
if (IsJSRuntimeAvailable && JSRuntime != null)
{
var svg = await ToSvg();
await JSRuntime.InvokeVoidAsync("Radzen.downloadSvgAsPng", svg, fileName, width, height);
}
}
///
/// Renders the chart as a PNG image and returns the image data.
/// The plot area and axes are included; legends and tooltips are HTML overlays and are not included.
/// Use this to store the chart in a database, embed it in documents, or send it to an API instead of downloading it.
///
/// The PNG width in pixels. When null the rendered size scaled by the device pixel ratio is used.
/// The PNG height in pixels. When null it is derived from preserving the aspect ratio.
///
/// A representing the asynchronous operation. The task result contains the PNG image data.
///
public async Task ToPng(int? width, int? height = null)
{
if (IsJSRuntimeAvailable && JSRuntime != null)
{
var svg = await ToSvg();
if (!string.IsNullOrEmpty(svg))
{
await using var png = await JSRuntime.InvokeAsync("Radzen.svgToPng", svg, width, height);
using var stream = await png.OpenReadStreamAsync(maxAllowedSize: 32 * 1024 * 1024);
using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream);
return memoryStream.ToArray();
}
}
return Array.Empty();
}
///
public override void Dispose()
{
base.Dispose();
UnregisterSyncGroup();
if (IsJSRuntimeAvailable && JSRuntime != null)
{
JSRuntime.InvokeVoid("Radzen.disposeElement", Element);
}
GC.SuppressFinalize(this);
}
///
protected override string GetComponentCssClass()
{
var css = $"rz-chart rz-scheme-{ColorScheme.ToString().ToLowerInvariant()}";
if (AllowSeriesHover)
{
css += " rz-chart-series-hover";
}
if (Animate)
{
css += " rz-chart-animate";
}
if (AllowZoom || AllowPan)
{
css += " rz-chart-zoomable";
}
return css;
}
private bool ActivePointEnabled => Tooltip != null && Tooltip.Visible && Tooltip.HighlightDataPoint;
internal HoverOverlay? HoverOverlay { get; set; }
internal TooltipOverlay? TooltipOverlay { get; set; }
internal RadzenChartRangeNavigator? RangeNavigator { get; set; }
private readonly List<(double Top, double Bottom)> valueLabelReservations = new List<(double Top, double Bottom)>();
internal void ResetValueLabelLayout()
{
valueLabelReservations.Clear();
dataLabelReservations.Clear();
}
private readonly List<(double X, double Y, double Width, double Height)> dataLabelReservations = new List<(double X, double Y, double Width, double Height)>();
internal bool ReserveDataLabelRect(double x, double y, double width, double height)
{
foreach (var (rx, ry, rw, rh) in dataLabelReservations)
{
if (x < rx + rw && x + width > rx && y < ry + rh && y + height > ry)
{
return false;
}
}
dataLabelReservations.Add((x, y, width, height));
return true;
}
internal double ReserveValueLabelSlot(double desiredY, double height, double plotHeight)
{
var half = height / 2;
var y = Math.Clamp(desiredY, half, Math.Max(half, plotHeight - half));
bool Overlaps(double candidate)
{
foreach (var (top, bottom) in valueLabelReservations)
{
if (candidate - half < bottom && candidate + half > top)
{
return true;
}
}
return false;
}
if (Overlaps(y))
{
for (var offset = 1.0; offset <= plotHeight; offset += 1)
{
if (y + offset + half <= plotHeight && !Overlaps(y + offset))
{
y += offset;
break;
}
if (y - offset - half >= 0 && !Overlaps(y - offset))
{
y -= offset;
break;
}
}
}
valueLabelReservations.Add((y - half, y + half));
return y;
}
internal async Task OnRangeNavigatorViewChanged(double start, double end)
{
var newStart = Math.Clamp(Math.Min(start, end), 0, 1);
var newEnd = Math.Clamp(Math.Max(start, end), 0, 1);
if (newStart == ZoomStart && newEnd == ZoomEnd)
{
return;
}
ZoomStart = newStart;
ZoomEnd = newEnd;
isInternalZoom = true;
try
{
await NotifyZoomChanged();
await Refresh();
}
finally
{
isInternalZoom = false;
}
}
internal RenderFragment RenderActivePoint()
{
return builder =>
{
var synced = !MouseInside && SyncedPlotX.HasValue;
if (!ActivePointEnabled || (!MouseInside && !synced) || (MouseInside && SnapPlotX < 0 && SnapPlotY < 0))
{
return;
}
var points = new List<(IChartSeries Series, double X, double Y)>();
if (synced)
{
foreach (var series in Series.OrderBy(s => s.RenderingOrder))
{
if (!series.Visible || IsRegionSeries(series) || !series.ShowActivePoint)
{
continue;
}
var (data, point) = series.DataAt(SyncedPlotX!.Value, 0);
if (data != null)
{
points.Add((series, point.X, point.Y));
}
}
}
else if (Tooltip!.Shared && SharedPointsAtSnap != null)
{
foreach (var (series, _, point) in SharedPointsAtSnap)
{
if (!IsRegionSeries(series) && series.ShowActivePoint)
{
points.Add((series, point.X, point.Y));
}
}
}
else if (HoveredSeries != null && !IsRegionSeries(HoveredSeries) && HoveredSeries.ShowActivePoint)
{
points.Add((HoveredSeries, SnapPlotX, SnapPlotY));
}
if (points.Count == 0)
{
return;
}
builder.OpenElement(0, "g");
builder.AddAttribute(1, "class", "rz-chart-active-points");
builder.AddAttribute(2, "pointer-events", "none");
foreach (var (series, x, y) in points)
{
var index = Series.IndexOf(series);
var size = Math.Max(3, series.MarkerSize);
var style = $"transform: translate({x.ToInvariantString()}px, {y.ToInvariantString()}px)";
if (!string.IsNullOrEmpty(series.Color))
{
style = $"--rz-series-color: {series.Color}; {style}";
}
builder.OpenElement(3, "g");
builder.SetKey(index);
builder.AddAttribute(4, "class", $"rz-series-{index} rz-active-point");
builder.AddAttribute(5, "style", style);
builder.OpenElement(6, "circle");
builder.AddAttribute(7, "class", "rz-active-point-halo");
builder.AddAttribute(8, "r", (size * 2.4).ToInvariantString());
builder.CloseElement();
var markerPath = Rendering.MarkerPath.For(series.MarkerType, 0, 0, size);
if (markerPath.Length > 0)
{
builder.OpenElement(9, "path");
builder.AddAttribute(10, "class", "rz-active-point-dot");
builder.AddAttribute(11, "d", markerPath);
builder.CloseElement();
}
else
{
builder.OpenElement(12, "circle");
builder.AddAttribute(13, "class", "rz-active-point-dot");
builder.AddAttribute(14, "r", size.ToInvariantString());
builder.CloseElement();
}
builder.CloseElement();
}
builder.CloseElement();
};
}
internal RenderFragment RenderCrosshair()
{
return builder =>
{
var synced = !MouseInside && SyncedPlotX.HasValue;
if ((!MouseInside && !synced) || Tooltip == null || !Tooltip.Visible)
{
return;
}
if (!Width.HasValue || !Height.HasValue)
{
return;
}
var plotWidth = Width.Value - MarginLeft - MarginRight;
var plotHeight = Height.Value - MarginTop - MarginBottom;
if (plotWidth <= 0 || plotHeight <= 0)
{
return;
}
var categoryCrosshair = CategoryAxis?.Crosshair;
var hoveredValueAxis = GetValueAxis((HoveredSeries as IChartValueAxisSeries)?.ValueAxisName);
var hoveredValueScale = ShouldInvertAxes() ? ValueScale : GetValueScale((HoveredSeries as IChartValueAxisSeries)?.ValueAxisName);
var valueCrosshair = hoveredValueAxis?.Crosshair;
var snapX = categoryCrosshair?.Snap ?? true;
var queryX = synced ? SyncedPlotX!.Value : mouseX - MarginLeft;
var queryY = mouseY - MarginTop;
if (!synced && (queryX < 0 || queryX > plotWidth || queryY < 0 || queryY > plotHeight))
{
return;
}
double lineX = snapX ? (NearestDataPointX(queryX, queryY) ?? queryX) : queryX;
var lineY = queryY;
var showX = categoryCrosshair?.Visible == true && lineX >= 0 && lineX <= plotWidth;
var showY = !synced && valueCrosshair?.Visible == true && lineY >= 0 && lineY <= plotHeight;
if (!showX && !showY)
{
return;
}
builder.OpenElement(0, "g");
builder.AddAttribute(1, "class", "rz-chart-crosshair");
builder.AddAttribute(2, "pointer-events", "none");
if (showX && categoryCrosshair != null)
{
RenderCrosshairLine(builder, 3, categoryCrosshair,
x1: lineX, x2: lineX, y1: 0, y2: plotHeight);
if (categoryCrosshair.Label && CategoryAxis != null)
{
var value = CategoryScale.Value(PixelToValue(CategoryScale, lineX));
if (value != null)
{
var text = CategoryAxis.Format(CategoryScale, value);
RenderAxisCrosshairLabel(builder, 4, text,
anchorX: lineX, anchorY: plotHeight,
placement: AxisCrosshairLabelPlacement.Bottom);
}
}
}
if (showY && valueCrosshair != null && hoveredValueAxis != null)
{
RenderCrosshairLine(builder, 5, valueCrosshair,
x1: 0, x2: plotWidth, y1: lineY, y2: lineY);
if (valueCrosshair.Label)
{
var value = hoveredValueScale.Value(PixelToValue(hoveredValueScale, lineY));
if (value != null)
{
var text = hoveredValueAxis.Format(hoveredValueScale, value);
RenderAxisCrosshairLabel(builder, 6, text,
anchorX: 0, anchorY: lineY,
placement: AxisCrosshairLabelPlacement.Left);
}
}
}
builder.CloseElement();
};
}
private static double PixelToValue(ScaleBase scale, double plotLocalPixel)
{
var outputDelta = scale.Output.End - scale.Output.Start;
var size = Math.Abs(outputDelta);
if (size == 0)
{
return scale.Input.Start;
}
var paddedSize = size - scale.Padding * 2;
if (paddedSize <= 0)
{
return scale.Input.Start;
}
var t = (plotLocalPixel - scale.Padding) / paddedSize;
if (t < 0)
{
t = 0;
}
else if (t > 1)
{
t = 1;
}
if (outputDelta < 0)
{
t = 1 - t;
}
if (scale.IsLogarithmic && scale.Input.Start > 0 && scale.Input.End > 0)
{
var logMin = Math.Log(scale.Input.Start);
var logMax = Math.Log(scale.Input.End);
return Math.Exp(logMin + t * (logMax - logMin));
}
return scale.Input.Start + t * (scale.Input.End - scale.Input.Start);
}
private static void RenderCrosshairLine(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder, int seqBase,
RadzenAxisCrosshair config, double x1, double x2, double y1, double y2)
{
var stroke = config.Stroke ?? "var(--rz-chart-crosshair-color, var(--rz-chart-axis-color, rgba(0,0,0,0.5)))";
var width = config.StrokeWidth;
string? dashArray = config.LineType switch
{
LineType.Dashed => $"{(width * 3).ToInvariantString()} {(width * 3).ToInvariantString()}",
LineType.Dotted => $"0 {(width * 2).ToInvariantString()}",
_ => null,
};
var lineCap = config.LineType == LineType.Dotted ? "round" : null;
builder.OpenRegion(seqBase);
builder.OpenElement(0, "line");
builder.AddAttribute(1, "x1", x1.ToInvariantString());
builder.AddAttribute(2, "x2", x2.ToInvariantString());
builder.AddAttribute(3, "y1", y1.ToInvariantString());
builder.AddAttribute(4, "y2", y2.ToInvariantString());
builder.AddAttribute(5, "stroke", stroke);
builder.AddAttribute(6, "stroke-width", width.ToInvariantString());
if (dashArray != null)
{
builder.AddAttribute(7, "stroke-dasharray", dashArray);
}
if (lineCap != null)
{
builder.AddAttribute(8, "stroke-linecap", lineCap);
}
builder.CloseElement();
builder.CloseRegion();
}
private enum AxisCrosshairLabelPlacement { Bottom, Left }
private static void RenderAxisCrosshairLabel(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder, int seqBase,
string text, double anchorX, double anchorY, AxisCrosshairLabelPlacement placement)
{
var paddingX = 6.0;
var textWidth = Rendering.TextMeasurer.TextWidth(text ?? string.Empty, 12.5);
var boxWidth = textWidth + paddingX * 2;
var boxHeight = 20.0;
double rectX, rectY, textX, textY;
switch (placement)
{
case AxisCrosshairLabelPlacement.Bottom:
rectX = anchorX - boxWidth / 2;
rectY = anchorY + 4;
textX = anchorX;
textY = rectY + boxHeight / 2;
break;
case AxisCrosshairLabelPlacement.Left:
default:
rectX = -boxWidth - 4;
rectY = anchorY - boxHeight / 2;
textX = rectX + boxWidth / 2;
textY = rectY + boxHeight / 2;
break;
}
builder.OpenRegion(seqBase);
builder.OpenElement(0, "g");
builder.AddAttribute(1, "class", "rz-chart-axis-crosshair-label");
builder.AddAttribute(2, "pointer-events", "none");
builder.OpenElement(3, "rect");
builder.AddAttribute(4, "x", rectX.ToInvariantString());
builder.AddAttribute(5, "y", rectY.ToInvariantString());
builder.AddAttribute(6, "width", boxWidth.ToInvariantString());
builder.AddAttribute(7, "height", boxHeight.ToInvariantString());
builder.AddAttribute(8, "rx", "2");
builder.AddAttribute(9, "ry", "2");
builder.CloseElement();
builder.OpenElement(10, "text");
builder.AddAttribute(11, "x", textX.ToInvariantString());
builder.AddAttribute(12, "y", textY.ToInvariantString());
builder.AddAttribute(13, "text-anchor", "middle");
builder.AddAttribute(14, "dy", "-0.0125em");
builder.AddContent(15, text);
builder.CloseElement();
builder.CloseElement();
builder.CloseRegion();
}
private List<(IChartSeries Series, object Data, Point Point)> SyncedPoints()
{
var list = new List<(IChartSeries Series, object Data, Point Point)>();
foreach (var series in Series.OrderBy(s => s.RenderingOrder))
{
if (!series.Visible || IsRegionSeries(series))
{
continue;
}
var (data, _) = series.DataAt(SyncedPlotX!.Value, 0);
if (data != null)
{
list.Add((series, data, series.GetTooltipPosition(data)));
}
}
return list;
}
internal bool CategoryTooltipRendered { get; private set; }
internal RenderFragment RenderTooltipOverlay()
{
return builder =>
{
CategoryTooltipRendered = false;
if (Tooltip == null || !Tooltip.Visible || !Width.HasValue || !Height.HasValue)
{
return;
}
var plotWidth = Width.Value - MarginLeft - MarginRight;
var plotHeight = Height.Value - MarginTop - MarginBottom;
if (plotWidth <= 0 || plotHeight <= 0)
{
return;
}
if (!MouseInside || !Tooltip.Split)
{
IChartSeries? series = null;
object? data = null;
Point? boxAnchor = null;
if (MouseInside && AxisTooltipTrigger && SnapPlotX >= 0 && tooltipSeries != null && tooltipData != null)
{
series = tooltipSeries;
data = tooltipData;
boxAnchor = new Point { X = SnapPlotX, Y = SnapPlotY };
}
else if (!MouseInside && SyncedPlotX.HasValue)
{
var synced = SyncedPoints();
if (synced.Count > 0)
{
(series, data, boxAnchor) = synced[0];
}
}
if (series == null || data == null || boxAnchor == null)
{
return;
}
CategoryTooltipRendered = true;
RenderCategoryTooltipBox(builder, series, data, boxAnchor, plotWidth, plotHeight);
return;
}
var points = SharedPointsAtSnap;
if (points == null || points.Count == 0)
{
return;
}
const double estTooltipHeight = 64;
const double estTooltipWidth = 220;
const double gap = 10;
const double minVerticalGap = 6;
var topLimit = MarginTop;
var bottomLimit = MarginTop + plotHeight - estTooltipHeight;
// For each series, resolve the preferred anchor (right of the data point by default,
// flipped left if the expected content would spill off the plot). Content sizes itself
// via CSS; we only control the container's top/left.
var placements = new List<(IChartSeries Series, object Data, double AnchorX, double AnchorY, bool RightSide)>();
foreach (var entry in points)
{
// entry.Point is in plot-local coords. Convert to chart-relative by adding margins.
var anchorX = entry.Point.X + MarginLeft;
var anchorY = entry.Point.Y + MarginTop;
var fitsRight = anchorX + gap + estTooltipWidth <= MarginLeft + plotWidth;
var fitsLeft = anchorX - gap - estTooltipWidth >= MarginLeft;
var rightSide = IsRTL ? !fitsLeft : fitsRight;
placements.Add((entry.Series, entry.Data, anchorX, anchorY, rightSide));
}
var adjustedAnchorY = new double[placements.Count];
// Resolve collisions per side independently, sliding the whole stack up if it overflows the bottom.
foreach (var side in new[] { true, false })
{
var indices = Enumerable.Range(0, placements.Count)
.Where(i => placements[i].RightSide == side)
.OrderBy(i => placements[i].AnchorY)
.ToList();
if (indices.Count == 0)
{
continue;
}
// Initial Y positions (cursor anchored to data point).
var ys = indices.Select(i => placements[i].AnchorY).ToArray();
// Step 1: push each tooltip down so it doesn't overlap the previous one.
for (var k = 1; k < ys.Length; k++)
{
var minY = ys[k - 1] + estTooltipHeight + minVerticalGap;
if (ys[k] < minY)
{
ys[k] = minY;
}
}
// Step 2: if the stack overflows the bottom, slide everything up.
var bottomOverflow = ys[^1] - bottomLimit;
if (bottomOverflow > 0)
{
for (var k = 0; k < ys.Length; k++)
{
ys[k] -= bottomOverflow;
}
}
// Step 3: clamp top, then re-cascade in case the slide went above the top limit.
if (ys[0] < topLimit)
{
ys[0] = topLimit;
}
for (var k = 1; k < ys.Length; k++)
{
var minY = ys[k - 1] + estTooltipHeight + minVerticalGap;
if (ys[k] < minY)
{
ys[k] = minY;
}
}
for (var k = 0; k < indices.Count; k++)
{
adjustedAnchorY[indices[k]] = ys[k];
}
}
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "rz-chart-split-tooltip");
builder.AddAttribute(2, "style", "position: absolute; inset: 0; pointer-events: none; overflow: hidden;");
var seq = 3;
for (var i = 0; i < placements.Count; i++)
{
var p = placements[i];
var ty = adjustedAnchorY[i];
// Position the container at (AnchorX ± gap, ty). For right-side, content flows left-to-right;
// for left-side, we use right: instead of left so the content's right edge anchors near the data point.
string positionStyle;
if (p.RightSide)
{
var left = p.AnchorX + gap;
positionStyle = $"left: {left.ToInvariantString()}px;";
}
else
{
// right = distance from chart's right edge to where the tooltip's right edge should sit.
var right = Width!.Value - (p.AnchorX - gap);
positionStyle = $"right: {right.ToInvariantString()}px;";
}
var borderSide = p.RightSide ? "border-left" : "border-right";
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "class", $"rz-chart-split-tooltip-item{(p.RightSide ? "" : " rz-chart-tooltip-left")}");
builder.AddAttribute(seq++, "style",
$"position: absolute; top: {ty.ToInvariantString()}px; {positionStyle} " +
$"{borderSide}: 3px solid {p.Series.Color}; pointer-events: none;");
builder.AddContent(seq++, p.Series.RenderTooltip(p.Data));
builder.CloseElement(); // tooltip item
}
builder.CloseElement(); // container div
};
}
private void RenderCategoryTooltipBox(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder,
IChartSeries series, object data, Point anchor, double plotWidth, double plotHeight)
{
const double gap = 10;
const double estTooltipHeight = 64;
const double estTooltipWidth = 220;
var anchorX = anchor.X + MarginLeft;
var anchorY = anchor.Y + MarginTop;
var fitsRight = anchorX + gap + estTooltipWidth <= MarginLeft + plotWidth;
var fitsLeft = anchorX - gap - estTooltipWidth >= MarginLeft;
var rightSide = IsRTL ? !fitsLeft : fitsRight;
var top = Math.Max(MarginTop, Math.Min(MarginTop + plotHeight - estTooltipHeight, anchorY - estTooltipHeight / 2));
string positionStyle;
if (rightSide)
{
positionStyle = $"left: {(anchorX + gap).ToInvariantString()}px;";
}
else
{
positionStyle = $"right: {(Width!.Value - (anchorX - gap)).ToInvariantString()}px;";
}
var borderSide = rightSide ? "border-left" : "border-right";
var entering = !(TooltipOverlay?.BoxWasVisible ?? false);
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "rz-chart-split-tooltip");
builder.AddAttribute(2, "style", "position: absolute; inset: 0; pointer-events: none; overflow: hidden;");
builder.OpenElement(3, "div");
builder.AddAttribute(4, "class", $"rz-chart-category-tooltip{(rightSide ? "" : " rz-chart-tooltip-left")}{(entering ? " rz-chart-tooltip-appear" : "")}");
builder.AddAttribute(5, "style",
$"position: absolute; top: {top.ToInvariantString()}px; {positionStyle} " +
$"{borderSide}: 3px solid {series.Color}; pointer-events: none;");
builder.AddContent(6, series.RenderTooltip(data));
builder.CloseElement();
builder.CloseElement();
}
}
}