beto-rodriguez / LiveCharts2

Simple, flexible, interactive & powerful charts, maps and gauges for .Net, LiveCharts2 can now practically run everywhere Maui, Uno Platform, Blazor-wasm, WPF, WinForms, Xamarin, Avalonia, WinUI, UWP.
https://livecharts.dev
MIT License
4.36k stars 569 forks source link

TooltipFindingStrategy.CompareAllTakeClosest difficult to use with small geometries #1248

Open garyhertel opened 1 year ago

garyhertel commented 1 year ago

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

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 commented 1 year ago

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)

mpogra commented 1 year ago

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.

garyhertel commented 1 year ago

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 commented 1 year ago

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?

mpogra commented 1 year ago

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?

garyhertel commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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;
    }
}
mpogra commented 1 year ago

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

garyhertel commented 1 year ago

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.

leoslima13 commented 8 months ago

@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

garyhertel commented 8 months ago

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