LiveCharts2 icon indicating copy to clipboard operation
LiveCharts2 copied to clipboard

TooltipFindingStrategy.CompareAllTakeClosest difficult to use with small geometries

Open garyhertel opened this issue 1 year ago • 12 comments

Is your feature request related to a problem? Please describe.

Using a CartesianChart with TooltipFindingStrategy set to "CompareAllTakeClosest", the points become really hard to hover over for a tooltip or select, requiring almost pixel perfect accuracy. It looks like this might be because it's using the GeometrySize for the hover area height?

https://github.com/beto-rodriguez/LiveCharts2/blob/master/src/LiveChartsCore/LineSeries.cs#L369

Increasing the GeometrySize does seem to make these points easier to select too. However, large geometries don't tend to look good with lots of points (100+), but small geometries like 3-5 are still useful for few points or isolated data points surrounded by null values that would otherwise be hidden. (that one might be nice to auto enable markers for just that point too?)

To Reproduce Steps to reproduce the behavior:

  1. Modifying the Avalonia Sample for Lines/Straight, Set the TooltipFindingStrategy to "CompareAllTakeClosest"
 <lvc:CartesianChart Series="{Binding Series}" TooltipFindingStrategy="CompareAllTakeClosest"/>

https://github.com/beto-rodriguez/LiveCharts2/blob/master/samples/AvaloniaSample/Lines/Straight/View.axaml#L9 2. Start the Avalonia Sample demo and go to the Lines/Straight 3. Try to hover over the points in each line and note how close you have to get

Describe the solution you'd like

  • The TooltipFindingStrategy.CompareAllTakeClosest should take the closest points within x distance that is independent of the GeometrySize and Axis UnitWidths
  • A good default is probably 15-20 pixels? Although it would be nice for this to be configurable

Describe alternatives you've considered A clear and concise description of any alternative solutions or features you've considered.

Additional context Add any other context or screenshots about the feature request here.

garyhertel avatar Sep 17 '23 19:09 garyhertel

One other thought it is this could run into trouble with LiveCharts showing multiple series in a tooltip combined with the tooltip clipping issue, since it might find more series with a wider search. In the long term showing multiple series in the tooltip is probably the best option, but with the tooltip clipping issue that might not always be the case right now.

(Note that I currently use OxyPlot which only shows the single nearest series, and also has a much looser point selection)

garyhertel avatar Sep 17 '23 19:09 garyhertel

I am also struggling with this issue, getting all points from all series and it's hard to show the tooltip of the exact series. Tried using TooltipFindingStrategy and even I tried to compare the distance from ToolTipLocation but nothing worked, it is sometimes good but sometimes worse -


_stackPanel?.Children.Add(tableLayout);
            var size = _stackPanel.Measure(chart);
            var location = foundPoints.GetTooltipLocation(size, chart);
            LvcPoint lp1 = new LvcPoint(location.X, location.Y);
            logger.Debug("Out xy is: " + p1.X + ", y=" + p1.Y + ", ToolTip:" + location.X + ", " + location.Y);

in all found points I am doing the below to get the minimum distance from the tooltip location -


 LineSeries<ObservablePoint> ls = (LineSeries<ObservablePoint>)point.Context.Series;
                //double d1 = point.DistanceTo(lp);
                double d = point.DistanceTo(lp1);

Any ideas? It wasn't like this in LiveChart v1.0. Seems like TooltipFindingStrategy compares with all points available not just with the points found within that particular hover area.

mpogra avatar Sep 18 '23 05:09 mpogra

I was running into a similar problem trying to detect whether the background or point was clicked since it always finds a point. Here's my current solution to work around this based on some modifications to the original code. I added some GH comments around the workarounds & fixes, which would be nice to get fixed in LiveCharts. Or some simpler method/property option to get the same results:

public class LiveChartLineSeries : LineSeries<ObservablePoint>
{
	// GH: Make public instead of protected
	public new IEnumerable<ChartPoint> Fetch(IChart chart) => base.Fetch(chart);
}

List<ChartPoint> FindHitPoints(LvcPoint pointerPosition, double maxDistance)
{
	return LiveChartSeries
		.SelectMany(s => s.LineSeries.Fetch(Chart.CoreChart))
		.Select(x => new { distance = GetDistanceTo(x, pointerPosition), point = x })
		.Where(x => x.distance < maxDistance)
		.OrderBy(x => x.distance)
		.SelectFirst(x => x.point)
		.ToList();
}

public static double GetDistanceTo(ChartPoint target, LvcPoint location)
{
	// GH: Original code has a bug here checking the Context instead of Context.Chart
	if (target.Context.Chart is not ICartesianChartView<SkiaSharpDrawingContext> cartesianChart)
	{
		throw new NotImplementedException();
	}
		
	var cartesianSeries = (ICartesianSeries<SkiaSharpDrawingContext>)target.Context.Series;

	var primaryAxis = cartesianChart.Core.YAxes[cartesianSeries.ScalesXAt];
	var secondaryAxis = cartesianChart.Core.XAxes[cartesianSeries.ScalesYAt];

	var drawLocation = cartesianChart.Core.DrawMarginLocation;
	var drawMarginSize = cartesianChart.Core.DrawMarginSize;

	var secondaryScale = new Scaler(drawLocation, drawMarginSize, secondaryAxis);
	var primaryScale = new Scaler(drawLocation, drawMarginSize, primaryAxis);

	var coordinate = target.Coordinate;

	double x = secondaryScale.ToPixels(coordinate.SecondaryValue);
	double y = primaryScale.ToPixels(coordinate.PrimaryValue);

	// calculate the distance
	// GH: Original code incorrectly used dataCoordinates here instead
	var dx = location.X - x;
	var dy = location.Y - y;

	return Math.Sqrt(Math.Pow(dx, 2) + Math.Pow(dy, 2));
}

garyhertel avatar Sep 23 '23 22:09 garyhertel

hmm...it does look like this does still have problems with DateTime axis. Not sure if it's a rounding issue or what since it does seem to work with axis that use smaller units. Maybe there's a float that should be a double someplace?

garyhertel avatar Sep 23 '23 22:09 garyhertel

I was running into a similar problem trying to detect whether the background or point was clicked since it always finds a point. Here's my current solution to work around this based on some modifications to the original code. I added some GH comments around the workarounds & fixes, which would be nice to get fixed in LiveCharts. Or some simpler method/property option to get the same results:

public class LiveChartLineSeries : LineSeries<ObservablePoint>
{
	// GH: Make public instead of protected
	public new IEnumerable<ChartPoint> Fetch(IChart chart) => base.Fetch(chart);
}

List<ChartPoint> FindHitPoints(LvcPoint pointerPosition, double maxDistance)
{
	return LiveChartSeries
		.SelectMany(s => s.LineSeries.Fetch(Chart.CoreChart))
		.Select(x => new { distance = GetDistanceTo(x, pointerPosition), point = x })
		.Where(x => x.distance < maxDistance)
		.OrderBy(x => x.distance)
		.SelectFirst(x => x.point)
		.ToList();
}

public static double GetDistanceTo(ChartPoint target, LvcPoint location)
{
	// GH: Original code has a bug here checking the Context instead of Context.Chart
	if (target.Context.Chart is not ICartesianChartView<SkiaSharpDrawingContext> cartesianChart)
	{
		throw new NotImplementedException();
	}
		
	var cartesianSeries = (ICartesianSeries<SkiaSharpDrawingContext>)target.Context.Series;

	var primaryAxis = cartesianChart.Core.YAxes[cartesianSeries.ScalesXAt];
	var secondaryAxis = cartesianChart.Core.XAxes[cartesianSeries.ScalesYAt];

	var drawLocation = cartesianChart.Core.DrawMarginLocation;
	var drawMarginSize = cartesianChart.Core.DrawMarginSize;

	var secondaryScale = new Scaler(drawLocation, drawMarginSize, secondaryAxis);
	var primaryScale = new Scaler(drawLocation, drawMarginSize, primaryAxis);

	var coordinate = target.Coordinate;

	double x = secondaryScale.ToPixels(coordinate.SecondaryValue);
	double y = primaryScale.ToPixels(coordinate.PrimaryValue);

	// calculate the distance
	// GH: Original code incorrectly used dataCoordinates here instead
	var dx = location.X - x;
	var dy = location.Y - y;

	return Math.Sqrt(Math.Pow(dx, 2) + Math.Pow(dy, 2));
}

Can you share a little bit more details, such as where exactly you have added these functions?

mpogra avatar Sep 27 '23 08:09 mpogra

These were mostly just test functions to see if it's possible to use. Unfortunately since it doesn't seem to work with the DateTime Axis, it's not too useful for me since that's the majority of my use cases. That's probably fixable, although this is getting to be a bit too complex for my liking, and it would be good to see some LiveCharts changes to make this easier.

I was also using these functions for finding points when clicking, not for showing tooltips, although that would probably be the next step.

private void LiveChart_PointerPressed(object? sender, global::Avalonia.Input.PointerPressedEventArgs e)
{
	var point = e.GetPosition(this);
	var hitPoint = FindHitPoints(new LvcPoint(point.X, point.Y), 20);

}

garyhertel avatar Sep 29 '23 16:09 garyhertel

Okay, after a bit more debugging I found my problem with the pixels being off, and it wasn't related to the DateTime axis after all. I was calling GetPosition with the wrong control. After switching that it's giving me valid points. So the modified FindHitPoints() might be useful after all in the meantime. :)

private void LiveChart_PointerPressed(object? sender, global::Avalonia.Input.PointerPressedEventArgs e)
{
	var point = e.GetPosition(chart); // Was referencing the parent control instead
	var hitPoint = FindHitPoints(new LvcPoint(point.X, point.Y), 20);

}

garyhertel avatar Sep 30 '23 22:09 garyhertel

Okay, here's my updated workaround for showing the tooltips based on pixel distance. It actually works surprisingly well thanks to the flexibility of LiveCharts.

public class LiveChartLineSeries : LineSeries<ObservablePoint>, ISeries
{
	IEnumerable<ChartPoint> ISeries.FindHitPoints(IChart chart, LvcPoint pointerPosition, TooltipFindingStrategy strategy)
	{
		return FindHitPoints(chart, pointerPosition, 30);
	}

	List<ChartPoint> FindHitPoints(IChart chart, LvcPoint pointerPosition, double maxDistance)
	{
		return Fetch(chart)
			.Select(x => new { distance = GetDistanceTo(x, pointerPosition), point = x })
			.Where(x => x.distance < maxDistance)
			.OrderBy(x => x.distance)
			.SelectFirst(x => x.point)
			.ToList();
	}

	public static double GetDistanceTo(ChartPoint target, LvcPoint location)
	{
		if (target.Context.Chart is not ICartesianChartView<SkiaSharpDrawingContext> cartesianChart)
		{
			throw new NotImplementedException();
		}

		var cartesianSeries = (ICartesianSeries<SkiaSharpDrawingContext>)target.Context.Series;

		var primaryAxis = cartesianChart.Core.YAxes[cartesianSeries.ScalesXAt];
		var secondaryAxis = cartesianChart.Core.XAxes[cartesianSeries.ScalesYAt];

		var drawLocation = cartesianChart.Core.DrawMarginLocation;
		var drawMarginSize = cartesianChart.Core.DrawMarginSize;

		var secondaryScale = new Scaler(drawLocation, drawMarginSize, secondaryAxis);
		var primaryScale = new Scaler(drawLocation, drawMarginSize, primaryAxis);

		var coordinate = target.Coordinate;

		double x = secondaryScale.ToPixels(coordinate.SecondaryValue);
		double y = primaryScale.ToPixels(coordinate.PrimaryValue);

		// calculate the distance
		var dx = location.X - x;
		var dy = location.Y - y;

		double distance = Math.Sqrt(Math.Pow(dx, 2) + Math.Pow(dy, 2));
		return distance;
	}
}

garyhertel avatar Sep 30 '23 22:09 garyhertel

This seems to be really working well, thank you!!

mpogra avatar Oct 03 '23 13:10 mpogra

Glad that's working well for you!

This is mostly working well for me now. The one issue I still need to work through is figuring out what the closest point is among those returned. I'd like to be able to highlight the closest line, and fade out the others, but I probably need to override a bunch of other things in the chart itself.

garyhertel avatar Oct 04 '23 18:10 garyhertel

@garyhertel Your solution it's impressive good, I was having the same issue, and applying your changes it works much better. Thank you so much. I hope that this solution could go to LiveCharts source code

leoslima13 avatar Feb 20 '24 22:02 leoslima13

I'm glad that's been helpful! I have since added a few tweaks where it could sometimes reference the wrong axis, and decreased the max distance a bit, but otherwise I think it's the same:

	IEnumerable<ChartPoint> ISeries.FindHitPoints(IChart chart, LvcPoint pointerPosition, TooltipFindingStrategy strategy)
	{
		return FindHitPoints(chart, pointerPosition, 20);
	}

	List<ChartPoint> FindHitPoints(IChart chart, LvcPoint pointerPosition, double maxDistance)
	{
		if (!IsVisible) return new();

		return Fetch(chart)
			.Select(x => new { distance = GetDistanceTo(x, pointerPosition), point = x })
			.Where(x => x.distance < maxDistance)
			.OrderBy(x => x.distance)
			.SelectFirst(x => x.point)
			.ToList();
	}

	public static double GetDistanceTo(ChartPoint target, LvcPoint location)
	{
		if (target.Context.Chart is not ICartesianChartView<SkiaSharpDrawingContext> cartesianChart)
		{
			throw new NotImplementedException();
		}

		var cartesianSeries = (ICartesianSeries<SkiaSharpDrawingContext>)target.Context.Series;

		var primaryAxis = cartesianChart.Core.YAxes[cartesianSeries.ScalesYAt];
		var secondaryAxis = cartesianChart.Core.XAxes[cartesianSeries.ScalesXAt];

		var drawLocation = cartesianChart.Core.DrawMarginLocation;
		var drawMarginSize = cartesianChart.Core.DrawMarginSize;

		var secondaryScale = new Scaler(drawLocation, drawMarginSize, secondaryAxis);
		var primaryScale = new Scaler(drawLocation, drawMarginSize, primaryAxis);

		var coordinate = target.Coordinate;

		double x = secondaryScale.ToPixels(coordinate.SecondaryValue);
		double y = primaryScale.ToPixels(coordinate.PrimaryValue);

		// calculate the distance
		var dx = location.X - x;
		var dy = location.Y - y;

		double distance = Math.Sqrt(Math.Pow(dx, 2) + Math.Pow(dy, 2));
		return distance;
	}

garyhertel avatar Feb 21 '24 02:02 garyhertel