mirror of
https://github.com/radzenhq/radzen-blazor.git
synced 2026-07-08 18:16:08 +00:00
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:
committed by
Vladimir Enchev
parent
ae865660a6
commit
f58aa45b28
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user