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