fix(chart): correct tooltip hit-testing for line markers over columns

On a non-shared tooltip with overlapping column + line series, the column
won the hover because its DataAt reports the cursor position (distance 0),
and the tooltip popup did not refresh when switching between series that
share the same data objects.

Add FindClosestSeries with point-series priority (line/scatter/bubble win
over column/bar within tolerance) and include the series identity in the
tooltip de-duplication. Add a regression test.
This commit is contained in:
Maurycy Bartczak
2026-05-31 10:41:07 +02:00
committed by Vladimir Enchev
parent ae865660a6
commit f58aa45b28
2 changed files with 145 additions and 57 deletions

View File

@@ -54,4 +54,67 @@ public class ChartTests
}
output.WriteLine($"Time took: {stopwatch.Elapsed}");
}
private class MultiAxisItem
{
public string Month { get; set; } = "";
public double Revenue { get; set; }
public double Rate { get; set; }
}
// Tall columns (near the axis max) so the line markers, plotted on a separate 0-100 axis, sit
// visually inside the columns - the exact overlap that used to make the column win the hover.
private static readonly MultiAxisItem[] TallBarData =
{
new() { Month = "Jan", Revenue = 9000, Rate = 40 },
new() { Month = "Feb", Revenue = 8000, Rate = 45 },
new() { Month = "Mar", Revenue = 9500, Rate = 50 },
};
private static TestContext CreateChartContext()
{
var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
ctx.JSInterop.Setup<Radzen.Blazor.Rendering.Rect>("Radzen.createChart", _ => true)
.SetResult(new Radzen.Blazor.Rendering.Rect { Left = 0, Top = 0, Width = 600, Height = 400 });
ctx.Services.AddScoped<TooltipService>();
return ctx;
}
[Fact]
public void Line_Marker_On_Top_Of_Column_Wins_Hover_Selection()
{
using var ctx = CreateChartContext();
var chart = ctx.RenderComponent<RadzenChart>(parameters => parameters
.AddChildContent<RadzenColumnSeries<MultiAxisItem>>(series => series
.Add(p => p.CategoryProperty, nameof(MultiAxisItem.Month))
.Add(p => p.ValueProperty, nameof(MultiAxisItem.Revenue))
.Add(p => p.Data, TallBarData))
.AddChildContent<RadzenLineSeries<MultiAxisItem>>(series => series
.Add(p => p.CategoryProperty, nameof(MultiAxisItem.Month))
.Add(p => p.ValueProperty, nameof(MultiAxisItem.Rate))
.Add(p => p.ValueAxisName, "rate")
.Add(p => p.Data, TallBarData))
.AddChildContent<RadzenValueAxis>(a => a
.Add(p => p.Min, 0d).Add(p => p.Max, 10000d).Add(p => p.Step, 2000d))
.AddChildContent<RadzenValueAxis>(a => a
.Add(p => p.Name, "rate").Add(p => p.Min, 0d).Add(p => p.Max, 100d).Add(p => p.Step, 20d)));
var instance = chart.Instance;
var line = instance.Series.Single(s => (s as IChartValueAxisSeries)?.ValueAxisName == "rate");
var column = instance.Series.Single(s => s is IChartColumnSeries);
// A line data point that sits well inside the (taller) column for that category.
var item = TallBarData[2]; // Mar: column at ~95% height, line at 50%
var linePoint = line.GetTooltipPosition(item);
// Hovering the marker selects the line, even though the column also contains the point.
var (hovered, _) = instance.FindClosestSeries(linePoint.X, linePoint.Y, 25);
Assert.Same(line, hovered);
// Hovering the bar away from the line (well below the marker) still selects the column.
var (barHovered, _) = instance.FindClosestSeries(linePoint.X, linePoint.Y + 80, 25);
Assert.Same(column, barHovered);
}
}

View File

@@ -700,6 +700,7 @@ namespace Radzen.Blazor
RenderFragment? tooltip;
object? tooltipData;
IChartSeries? tooltipSeries;
double mouseX;
double mouseY;
@@ -832,32 +833,10 @@ namespace Radzen.Blazor
[JSInvokable]
public async Task Click(double x, double y)
{
IChartSeries? closestSeries = null;
object? closestSeriesData = null;
double closestSeriesDistanceSquared = ClickTolerance * ClickTolerance;
var queryX = x - MarginLeft;
var queryY = y - MarginTop;
foreach (var series in Series)
{
if (series.Visible)
{
var (seriesData, seriesDataPoint) = series.DataAt(queryX, queryY);
if (seriesData != null)
{
double xDelta = queryX - seriesDataPoint.X;
double yDelta = queryY - seriesDataPoint.Y;
double squaredDistance = xDelta * xDelta + yDelta * yDelta;
if (squaredDistance < closestSeriesDistanceSquared)
{
closestSeries = series;
closestSeriesData = seriesData;
closestSeriesDistanceSquared = squaredDistance;
}
}
}
}
var (closestSeries, closestSeriesData) = FindClosestSeries(queryX, queryY, ClickTolerance);
if (closestSeriesData != null && closestSeries != null)
{
@@ -939,55 +918,98 @@ namespace Radzen.Blazor
}
}
// A column/bar 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 area 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 => !IsAreaSeries(s)), queryX, queryY, tolerance);
if (pointHit.Item1 != null)
{
return pointHit;
}
return ClosestSeries(ordered.Where(IsAreaSeries), queryX, queryY, tolerance);
}
private static (IChartSeries?, object?) ClosestSeries(IEnumerable<IChartSeries> 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);
}
private static bool IsAreaSeries(IChartSeries series)
{
return series is IChartColumnSeries
|| series is IChartBarSeries
|| series is IChartStackedColumnSeries
|| series is IChartStackedBarSeries
|| series is IChartFullStackedColumnSeries
|| series is IChartFullStackedBarSeries;
}
internal async Task DisplayTooltip()
{
if (Tooltip.Visible)
{
var orderedSeries = Series.OrderBy(s => s.RenderingOrder).Reverse();
IChartSeries? closestSeries = null;
object? closestSeriesData = null;
double closestSeriesDistanceSquared = TooltipTolerance * TooltipTolerance;
var queryX = mouseX - MarginLeft;
var queryY = mouseY - MarginTop;
foreach (var series in orderedSeries)
foreach (var series in Series.OrderBy(s => s.RenderingOrder).Reverse())
{
if (series.Visible)
if (!series.Visible)
{
foreach (var overlay in series.Overlays.Reverse())
{
if (overlay.Visible && overlay.Contains(queryX, queryY, TooltipTolerance))
{
tooltipData = null;
tooltip = overlay.RenderTooltip(queryX, queryY);
var tooltipPosition = overlay.GetTooltipPosition(queryX, queryY);
TooltipService?.OpenChartTooltip(Element, tooltipPosition.X + MarginLeft, tooltipPosition.Y + MarginTop, _ => tooltip, new ChartTooltipOptions
{
ColorScheme = ColorScheme
});
await Task.Yield();
continue;
}
return;
}
}
var (seriesData, seriesDataPoint) = series.DataAt(queryX, queryY);
if (seriesData != null)
foreach (var overlay in series.Overlays.Reverse())
{
if (overlay.Visible && overlay.Contains(queryX, queryY, TooltipTolerance))
{
double xDelta = queryX - seriesDataPoint.X;
double yDelta = queryY - seriesDataPoint.Y;
double squaredDistance = xDelta * xDelta + yDelta * yDelta;
if (squaredDistance < closestSeriesDistanceSquared)
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
{
closestSeries = series;
closestSeriesData = seriesData;
closestSeriesDistanceSquared = squaredDistance;
}
ColorScheme = ColorScheme
});
await Task.Yield();
return;
}
}
}
var (closestSeries, closestSeriesData) = FindClosestSeries(queryX, queryY, TooltipTolerance);
if (closestSeriesData != null && closestSeries != null)
{
var snap = closestSeries.GetTooltipPosition(closestSeriesData);
@@ -1021,9 +1043,10 @@ namespace Radzen.Blazor
SharedPointsAtSnap = null;
}
if (closestSeriesData != tooltipData)
if (closestSeriesData != tooltipData || !ReferenceEquals(closestSeries, tooltipSeries))
{
tooltipData = closestSeriesData;
tooltipSeries = closestSeries;
tooltip = closestSeries.RenderTooltip(closestSeriesData);
// Split mode draws its own in-chart overlay — don't also open the popup tooltip.
@@ -1061,6 +1084,7 @@ namespace Radzen.Blazor
if (tooltip != null)
{
tooltipData = null;
tooltipSeries = null;
tooltip = null;
TooltipService?.Close();
@@ -1094,6 +1118,7 @@ namespace Radzen.Blazor
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