Repository: pyqtgraph/pyqtgraph

Issue:
InfiniteLine mouse behavior failures at very small angles
<!-- In the following, please describe your issue in detail! -->
<!-- If some of the sections do not apply, just remove them. -->

### Short description
<!-- This should summarize the issue. -->

Mouse clicking, hovering and dragging is always active on an InfiniteLine object drawn at a very small absolute angle irrespective of the mouse position within the view box.

### Code to reproduce
<!-- Please provide a minimal working example that reproduces the issue in the code block below.
     Ideally, this should be a full example someone else could run without additional setup. -->
```python
import pyqtgraph as pg

import PySide6
import pyqtgraph as pg

app = pg.mkQApp()

plt = pg.plot()
xr=[]
for i in range(10):
    xr.append(i*1.0e+06)

sample = plt.plot(xr,range(10))

infline=pg.InfiniteLine(pos=[1.0e+06,2],angle=4.0e-05,movable=True)
plt.addItem(infline)

plt.show()

app.exec()

```
### Expected behavior
<!-- What should happen? -->

Normal mouse clicking, hovering and dragging behavior.

### Real behavior
<!-- What happens? -->

Mouse clicking, hovering and dragging are always active on the InfiniteLine object regardless of whether the mouse pointer is over this object or elsewhere in the view box.  This happens both with the PySide6 and default (PyQt5) config.

### Tested environment(s)

 * PyQtGraph version: <!-- output of pyqtgraph.__version__ -->0.12.1
 * Qt Python binding: <!-- output of pyqtgraph.Qt.VERSION_INFO -->PyQt5 5.15.4 Qt 5.15.2
 * Python version: Python 3.9.5
 * NumPy version: <!-- output of numpy.__version__ -->N/A
 * Operating system: Linux
 * Installation method: <!-- e.g. pip, conda, system packages, ... -->pip




Discussion:
Hi @edwinsnow thanks for reporting!

This appears to have been an issue for quite some time (going back to 0.10.0 at least).  I'm surprised this hasn't been noticed earlier.  

What's odd here though is the angled line in our [InfiniteLine Examples](https://github.com/pyqtgraph/pyqtgraph/blob/master/examples/InfiniteLine.py) behaves as expected.
I cannot run this example because of errors with the 'TargetItem', may be because the distributed package is not yet up to date with the latest version of the example.

However, the mouse behavior is indeed normal with regular angles, like 30 degrees or 5 degrees or 78 degrees.  My initial example is intentionally made in such way that the values on the x axis are orders of magnitude (1.0e+06) greater than those on the y axis.  As a result, an angle that looks 'regular' on the plot, like 40 degrees, has the actual value of 40e-06 degrees in the initial example, i.e. very small.  And this is where the mouse behavior fails.

First guess is that the function that tests the distance from the mouse pointer to the object takes the actual values instead of pixels for the purposes of resolving the behavior modes, i.e. somewhere something like mapToDevice() is missing.  But it is difficult to locate it without the detailed knowledge of the inner workings of the package. 
Thanks for highlighting the axis scales; this may be the MWE needed to try and see if #1557 would make an improvement; so this _might_ be a duplicate of #1537 (but I'm going to keep this open regardless due to the MWE demonstrating the issue).
Dear @edwinsnow , could you clarify one point?

Do you see this behavior whenever the InfiniteLine is basically a diagonal of the viewport?
From a very quick look at the code, it seems like it would grab mouse interaction events everywhere in a rectangular region that contains the entire visible line. If the line runs from e.g. the bottom left of the plot to the top right, then that will be the entire plot area.

The normal use case is a vertical or horizontal line, for which the bounding box agrees much better with the shape of the line.

If this is indeed the problem, then the solution is probably to make InfiniteLine inspect the actual coordinates of a mouse interaction, and decide by itself if those are close enough to the line. Not sure when I will have time to look at it in detail, though, sorry... If you want to have a go at it yourself @edwinsnow , then I would start here:
InfiniteLine.py: L586ff:
```python
    def mouseDragEvent(self, ev):
        if self.movable and ev.button() == QtCore.Qt.MouseButton.LeftButton:
            if ev.isStart():
                self._moving = True
                self._cursorOffset = self._posToRel(ev.buttonDownPos())
                self._startPosition = self.orthoPos
            ev.accept()
```
It might be sufficient to check self._cursorOffset and `return` rather than `ev.accept()` if it is too large?
> Dear @edwinsnow , could you clarify one point?
> 
> Do you see this behavior whenever the InfiniteLine is basically a diagonal of the viewport?
> From a very quick look at the code, it seems like it would grab mouse interaction events everywhere in a rectangular region that contains the entire visible line. If the line runs from e.g. the bottom left of the plot to the top right, then that will be the entire plot area.
> 
Apparently, this is not the case.  The example below is similar to the initial example both visually and relationally, but the x axis scale is 1.0e+06 times smaller.  In this example, the mouse behaves normally despite the line crossing the entire view box from the bottom to the top:
```python
import PySide6
import pyqtgraph as pg

app = pg.mkQApp()

plt = pg.plot()
xr=[]
for i in range(10):
    xr.append(i)

sample = plt.plot(xr,range(10))

infline=pg.InfiniteLine(pos=[1,2],angle=40,movable=True)
plt.addItem(infline)

plt.show()

app.exec()
``` 
> If you want to have a go at it yourself @edwinsnow , then I would start here:
> InfiniteLine.py: L586ff:
> 
> ```python
>     def mouseDragEvent(self, ev):
>         if self.movable and ev.button() == QtCore.Qt.MouseButton.LeftButton:
>             if ev.isStart():
>                 self._moving = True
>                 self._cursorOffset = self._posToRel(ev.buttonDownPos())
>                 self._startPosition = self.orthoPos
>             ev.accept()
>
>```
> 
> It might be sufficient to check self._cursorOffset and `return` rather than `ev.accept()` if it is too large?

Thank you for your suggestion.  This above is an excerpt from InfiniteLIneLabel rather than InfiniteLine itself.  InfiniteLine processes self.cursorOffset as follows:

```
self.cursorOffset = self.pos() - self.mapToParent(ev.buttonDownPos())
```
This measures a distance towards self.pos() and as such is not particularly helpful for a potential override.  A one-liner function measuring a closest distance from the mouse-point towards the line _**in pixels**_ would be of help however, if such a function exists.


A less extreme example, but it does show that the infinite line gets activated even though the mouse isn't too near it.
i.e. we didn't need a magnitude of 1e6 to trigger it.
```python
import pyqtgraph as pg
import numpy as np

pg.mkQApp()
plt = pg.plot()
mag = 50
sample = plt.plot(np.arange(10) * mag, np.arange(10))
infline=pg.InfiniteLine(pos=(4.5 * mag, 4.5), angle=45 / mag, movable=True)
plt.addItem(infline)
plt.show()
pg.exec()
```
> A less extreme example, but it does show that the infinite line gets activated even though the mouse isn't too near it.
> i.e. we didn't need a magnitude of 1e6 to trigger it.
>

If infline crosses [0,0] then mouse point [0,2] activates it, meaning that activation distance is about 2% of an x-axis tick.  The n% threshold needs to be corrected to n-pixels threshold to fix the issue.  It would be helpful if somebody with the knowledge of the inner workings of the parent packages could pinpoint the code line where this threshold is effected within the packages.

EDIT:  Interestingly, if y-axis is magnified instead of x-axis, the effect remains the same: infline crossing [0,0] is activated by mouse point at ~[2,0], i.e. the activation distance equals to about 2% of y-axis tick.  This hints at possibility that somewhere x and y coordinates may be misplaced by each other, i.e. some function takes (y,x) as arguments instead of (x,y) 
I'm fairly suspicious that the error is here somewhere: 

https://github.com/pyqtgraph/pyqtgraph/blob/master/pyqtgraph/GraphicsScene/GraphicsScene.py#L422-L468

likely here: https://github.com/pyqtgraph/pyqtgraph/blob/master/pyqtgraph/GraphicsScene/GraphicsScene.py#L456

Still tinkering with the debugger here...

EDIT: on second thought, the issue appears to be somewhere in `InfiniteLine._computeBoundingRect`

The bounding rect it generates for the plot is far too large
Does #727 address this?
@ixjlyons how did I forget about that PR.  Yes #727 absolutely ~fixed~ fixes this issue.  I'm watching this code in the background a bit between work tasks, and realized we only take into account pixel density in the x-axis for determining the radius for mouse interactions.

If memory serves on that PR, the test suite was failing all sorts of left and right... we should try and figure out how to get the tests to pass and merge that guy into the library.
I've been taking a look at this and I'm a little puzzled by what's happening in `GraphicsItem.pixelVectors`, which is how the `InfiniteLine` maps its local (1, 0) & (0, 1) to the device coordinate system.

I've got a script put together to demonstrate what seems like an odd behavior for (0, 1). On the left is the plot [here](https://github.com/pyqtgraph/pyqtgraph/issues/1878#issuecomment-874420005) and on the right are the `InfiniteLine` local x (red) and y (green) unit vectors mapped to the device coordinate system and normalized.

Notice when you zoom in and out on the y axis of the `InfiniteLine` plot, the angle of the `InfiniteLine` on the screen changes because it has a fixed angle in plot coordinates, and the red lines on the right rotate with it. The green lines do not rotate in the way I would expect, however.

```python
import pyqtgraph as pg
from pyqtgraph import Point
from pyqtgraph.Qt import QtCore
import numpy as np

pg.mkQApp()


class InfLine(pg.InfiniteLine):

    sigBoundingRectUpdate = QtCore.Signal(object)

    def paint(self, p, *args):
        tr = p.transform()

        # bounding rect in blue
        p.setPen(pg.mkPen("b", width=1))
        p.drawRect(self.boundingRect())

        super().paint(p, args)

        # (1, 0) and (0, 1) in local coordinates in red and green
        p.setTransform(tr)
        p.setPen(pg.mkPen("r"))
        p.drawLine(Point(0, 0), Point(1, 0))
        p.setPen(pg.mkPen("g"))
        p.drawLine(Point(0, 0), Point(0, 1))

    def _computeBoundingRect(self):
        self.sigBoundingRectUpdate.emit(self)
        return super()._computeBoundingRect()


glw = pg.GraphicsLayoutWidget()

# same as pijyoi's example to demonstrate the issue
plt = glw.addPlot()
mag = 50
sample = plt.plot(np.arange(10) * mag, np.arange(10))
infline = InfLine(angle=45 / mag, movable=True)
plt.addItem(infline)

# plot to visualize the local basis vectors of the InfLine as mapped to the device
# coordinate system
# (1, 0) is solid red
# (0, 1) is solid green
# dashed lines indicate the orthogonal vector in the device coordinate system
plt2 = glw.addPlot()
plt2.setAspectLocked(True)
plt2.showGrid(True, True)
plt2.disableAutoRange()
plt2.setXRange(-2, 2)
plt2.setYRange(-2, 2)

xvec = pg.PlotCurveItem(pen=pg.mkPen("r"))
xvec_orth = pg.PlotCurveItem(pen=pg.mkPen("r", dash=[5, 5]))

yvec = pg.PlotCurveItem(pen=pg.mkPen("g"))
yvec_orth = pg.PlotCurveItem(pen=pg.mkPen("g", dash=[5, 5]))

plt2.addItem(xvec)
plt2.addItem(yvec)
plt2.addItem(xvec_orth)
plt2.addItem(yvec_orth)

leg = plt2.addLegend()
leg.addItem(xvec, "InfLine (1, 0) in device coords")
leg.addItem(yvec, "InfLine (0, 1) in device coords")

glw.show()


def onUpdate(infline):
    global xvec, yvec, xvec_orth, yvec_orth

    dt = infline.deviceTransform()
    vt = infline.viewTransform()
    if dt is None:
        return None, None


    #print(vt.m11(), vt.m12())
    #print(vt.m21(), vt.m22())

    # replicate the first parts of GraphicsItem.pixelVectors
    for curve, point in zip([(xvec, xvec_orth), (yvec, yvec_orth)], [Point(1, 0), Point(0, 1)]):
        dt.setMatrix(dt.m11(), dt.m12(), 0, dt.m21(), dt.m22(), 0, 0, 0, 1)

        dirLine = QtCore.QLineF(QtCore.QPointF(0, 0), point)

        viewDir = dt.map(dirLine)
        normView = viewDir.unitVector()
        normOrtho = normView.normalVector()

        curve[0].setData([normView.x1(), normView.x2()], [normView.y1(), normView.y2()])
        curve[1].setData([normOrtho.x1(), normOrtho.x2()], [normOrtho.y1(), normOrtho.y2()])
        #curve[0].setData([viewDir.x1(), viewDir.x2()], [viewDir.y1(), viewDir.y2()])


infline.sigBoundingRectUpdate.connect(onUpdate)
pg.mkQApp().exec_()
```

(edited to include the red/green lines on the infinite line)
As a quick follow-up before I move on to something else for now, I added a few lines to `InfiniteLine.paint()` to draw the lines from the origin to (1, 0) and (0, 1) and reversed the padding of the boundingRect so the full bounds can be seen:

![Screenshot_2021-07-17_14-43-12](https://user-images.githubusercontent.com/943602/126050603-752d2b95-a47c-477e-b1ec-9c9af3730106.png)

Resolving patch:
diff --git a/pyqtgraph/graphicsItems/InfiniteLine.py b/pyqtgraph/graphicsItems/InfiniteLine.py
--- a/pyqtgraph/graphicsItems/InfiniteLine.py
+++ b/pyqtgraph/graphicsItems/InfiniteLine.py
@@ -300,10 +300,12 @@ def _computeBoundingRect(self):
         vr = self.viewRect()  # bounds of containing ViewBox mapped to local coords.
         if vr is None:
             return QtCore.QRectF()
-        
-        px = self.pixelLength(direction=Point(1,0), ortho=True)  ## get pixel length orthogonal to the line
-        if px is None:
-            px = 0
+
+        # compute the pixel size orthogonal to the line
+        # this is more complicated than it seems, maybe it can be simplified
+        _, ortho = self.pixelVectors(direction=Point(1, 0))
+        px = 0 if ortho is None else ortho.y()
+
         pw = max(self.pen.width() / 2, self.hoverPen.width() / 2)
         w = (self._maxMarkerSize + pw + 1) * px
         br = QtCore.QRectF(vr)


Test patch: