[pyqtgraph] カーソルを追いかける十字線とその座標を表示するクラスを作った

# やりたい事
この動作をやってくれるクラスの作成

- カーソルを追従する十字線の作成
- ラベルにx, y座標を表示
    - ラベルの色はプロット線と同じになる
    - y軸の範囲外の場合はNone表示 


<img width=500 alt='class.gif' src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/779817/208dfa84-8df5-9da3-1334-614e051c7750.gif'>


# 環境
Mac OS
Python 3.8.5

PyQt5 5.15.2
PyQt5-sip 12.8.1
pyqtgraph 0.11.1

`pip install PyQt5 PyQt5-sip pyqtgraph`

# pyqtgraph.exsamples
```python
import pyqtgraph.examples as ex
ex.run()
```

で色々なサンプルグラフが見れます。今回参考にしたのはCrosshair / Mouse interactionです。
<img width=500 alt='exsamples.png' src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/779817/97cd2c42-b946-bedf-f8b5-421a22696586.png'>
# 作ったもの
```python
import dataclasses
from typing import Union
import sys

from PyQt5 import QtWidgets
import pyqtgraph as pg


@dataclasses.dataclass
class CursorCrossHairLabel(object):
    """カーソルを追従する十字線を作成してグラフに追加するクラス
    カーソルが座標内にあれば座標を表示する
    Attributes #
    ----------
    plotter: pg.PlotItem
        十字線を追加するグラフ
    label: pg.LabelItem
        座標を表示するラベル
    vertical_line: pg.InfiniteLine
        カーソルを追いかける縦線
    horizontal_line: pg.InfiniteLine
        カーソルを追いかける横線
    proxy: pg.SignalProxy
        self.cursor_movedの実行間隔を制御するオブジェクト
    y_label: str default='y'
        y値凡例の文字列
    label_font_size: Union[int, float] default=14
        ラベルフォントサイズ
    digit :int default=3
        座標の小数点以下表示を指定

    Examples
    ----------
    import dataclasses
    from typing import Union
    import sys

    from PyQt5 import QtWidgets
    import pyqtgraph as pg

    app = QtWidgets.QApplication(sys.argv)
    graph_widget = pg.GraphicsLayoutWidget()

    label = pg.LabelItem(justify='right')
    graph_widget.addItem(label)

    plotter = graph_widget.addPlot(row=0, col=0)
    plotter.showGrid(x=True, y=True, alpha=1)

    curve1 = pg.PlotCurveItem([i ** 2 for i in range(10)], pen=pg.mkPen('#f00'))
    curve2= pg.PlotCurveItem([2*i ** 2 for i in range(10)], pen=pg.mkPen('#0f0'))
    plotter.addItem(curve1)
    plotter.addItem(curve2)

    cross_hair = CursorCrossHairLabel(plotter, label)

    graph_widget.show()
    sys.exit(app.exec_())
    """
    plotter: pg.PlotItem
    label: pg.LabelItem
    y_label: str = 'y'
    label_font_size: Union[int, float] = 14
    digit: int = 3
    v_color: dataclasses.InitVar[str] = '#ffd700'
    v_width: dataclasses.InitVar[Union[int, float]] = 2
    h_color: dataclasses.InitVar[str] = '#ffd700'
    h_width: dataclasses.InitVar[Union[int, float]] = 2

    def __post_init__(self, v_color: str, v_width: Union[int, float], h_color: str, h_width: Union[int, float]) -> None:
        """コンストラクタの続き
        Parameters
        ----------
        v_color: str default='#ffd700'
            縦線の色
        v_width: Union[int, float] default=2
            縦線の幅
        h_color: str default='#ffd700'
            横線の色
        h_width: Union[int, float] default=2
            横線の幅
        """
        self.vertical_line = pg.InfiniteLine(angle=90, movable=False, pen=pg.mkPen(v_color, width=v_width))
        self.horizontal_line = pg.InfiniteLine(angle=0, movable=False, pen=pg.mkPen(h_color, width=h_width))
        # plotterに追加
        self.plotter.addItem(self.vertical_line, ignoreBounds=True)
        self.plotter.addItem(self.horizontal_line, ignoreBounds=True)

        self.proxy = pg.SignalProxy(self.plotter.scene().sigMouseMoved, rateLimit=60, slot=self.cursor_moved)

        self.set_label()

    def set_label(self):
        """座標の初期値(None)をラベルにセット"""
        coord_text = f"<span style='font-size: {self.label_font_size}pt'>x=None<br>"
        for y_index, curve in enumerate(self.plotter.curves, 1):
            curve_color: str = curve.opts['pen'].color().name()
            coord_text += f" <span style='color: {curve_color}'>{self.y_label}{y_index}=None</span><br>"
        self.label.setText(coord_text)

    def cursor_moved(self, window_coord) -> None:
        """カーソル位置に十字線移動と座標を表示する
        カーソルの座標がグラフ内であればx値を表示する
        カーソルのy座標にデータがあればy値を表示する。範囲外であればNoneを表示する。
        y座標ラベルはcurveと同じ色

        Parameters
        ----------
        window_coord: tuple
            画面のピクセル単位の座標, ex) (PyQt5.QtCore.QPointF(2.0, 44.0),)

        Notes
        ----------
        curveはpyqtgraph.PlotCurveItem()で作成しないとcurve_colorの個所でエラーが発生する
        ex) OK
        import pyqtgraph as pg
        window = pg.PlotWidget()
        curve = pg.PlotCurveItem(pen=pg.mkPen('#ff0000))
        window.addItem(curve)

        ex) Error
        import pyqtgraph as pg
        window = pg.PlotWidget()
        curve = window.plot(pen=...)

        関数内変数の説明===================
        pos: PyQt5.QtCore.QPointF
            カーソルの画面内の座標 ex) PyQt5.QtCore.QPointF(2.0, 44.0)
        cursor_point: QtCore.QPointF
            カーソルのグラフ内の座標 ex) PyQt5.QtCore.QPointF(269.0678171506131, 0.695852534562212)
        self.plotter.curves: List[pg.PlotCurveItem]
            plotter内にあるcurveのリスト ex) [<pg.PlotCurveItem object at ...>, ...]
        curve_color: str
            curveの色 ex) '#ff0000',  関係無いけどcurve.opts['pen'].color()の型はQtGui.QColor
        =================================
        """
        pos = window_coord[0]

        if self.plotter.sceneBoundingRect().contains(pos):
            cursor_point = self.plotter.vb.mapSceneToView(pos)
            x_cursor_point: float = cursor_point.x()
            coord_text = f"<span style='font-size: {self.label_font_size}pt'>x={x_cursor_point:.{self.digit}f}<br>"
            data_index = int(x_cursor_point)

            for label_index, curve in enumerate(self.plotter.curves, 1):
                curve_color = curve.opts['pen'].color().name()

                if len(curve.yData) != 0 and 0 <= data_index < len(curve.yData):
                    y_value = curve.yData[data_index]
                    coord_text += f" <span style='color: {curve_color}'>" \
                                  f"{self.y_label}{label_index}={y_value:.{self.digit}f}</span><br>"
                else:
                    coord_text += f" <span style='color: {curve_color}'>{self.y_label}{label_index}=None</span><br>"
            self.label.setText(coord_text)
            # 十字線移動
            self.vertical_line.setPos(cursor_point.x())
            self.horizontal_line.setPos(cursor_point.y())

```

## 引数

必須引数

- plotter: pyqtgraph.PlotItem, 十字線を表示するグラフ
- label: pyqtgraph.LabelItem, 座標を表示するラベル

その他

- y_label: str, y値の凡例文字変更, デフォルト='y'
- label_font_size: Union[int, float], ラベルフォントサイズ, デフォルト=14
-  digit :int 座標の小数点以下表示を指定, デフォルト=3

あとは十字線の色とか幅とか
 

# 使用例
実行結果が上のgifです

```python
import dataclasses
from typing import Union
import sys

import numpy as np
from PyQt5 import QtWidgets
import pyqtgraph as pg

app = QtWidgets.QApplication(sys.argv)

graph_widget = pg.GraphicsLayoutWidget()

label = pg.LabelItem(justify='right')
graph_widget.addItem(label)

plotter = graph_widget.addPlot(row=0, col=0)
plotter.showGrid(x=True, y=True, alpha=1)

data = np.linspace(0, 100, 1000)

curve = pg.PlotCurveItem(data, pen=pg.mkPen('#f00', width=3))
curve2 = pg.PlotCurveItem(data * 2, pen=pg.mkPen('#0f0', width=3))
curve3 = pg.PlotCurveItem(data * 3, pen=pg.mkPen('#00f', width=3))
plotter.addItem(curve)
plotter.addItem(curve2)
plotter.addItem(curve3)
# これ
cross_hair = CursorCrossHairLabel(plotter, label, y_label='sample', label_font_size=18)

graph_widget.show()
sys.exit(app.exec_())
```

ラベルとグラフが重なってほしく無い時

<img width=500 alt='class2.gif' src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/779817/3e9147f6-cb0f-eefd-460d-d9a83386b1b3.gif'>


```python
graph_widget = pg.GraphicsLayoutWidget()

plotter = graph_widget.addPlot()
plotter.showGrid(x=True, y=True, alpha=1)

label = pg.LabelItem(justify='right')
graph_widget.addItem(label)

```

# ラベル無いver
```python
import dataclasses
from typing import Union
import sys

from PyQt5 import QtWidgets
import pyqtgraph as pg


@dataclasses.dataclass
class CursorCrossHair(object):
    """カーソルを追従する十字線を作成してグラフに追加するクラス
    Attributes #
    ----------
    plotter: pg.PlotItem
        十字線を追加するグラフ
    vertical_line: pg.InfiniteLine
        カーソルを追いかける縦線
    horizontal_line: pg.InfiniteLine
        カーソルを追いかける横線
    proxy: pg.SignalProxy
        self.cursor_movedの実行間隔を制御するオブジェクト
    
    Examples
    ----------
    import dataclasses
    from typing import Union
    import sys

    from PyQt5 import QtWidgets
    import pyqtgraph as pg

    app = QtWidgets.QApplication(sys.argv)
    graph_widget = pg.GraphicsLayoutWidget()

    plotter = graph_widget.addPlot(row=0, col=0)
    plotter.showGrid(x=True, y=True, alpha=1)

    curve1 = pg.PlotCurveItem([i ** 2 for i in range(10)], pen=pg.mkPen('#f00'))
    curve2= pg.PlotCurveItem([2*i ** 2 for i in range(10)], pen=pg.mkPen('#0f0'))
    plotter.addItem(curve1)
    plotter.addItem(curve2)

    cross_hair = CursorCrossHair(plotter)

    graph_widget.show()
    sys.exit(app.exec_())
    """
    plotter: pg.PlotItem
    v_color: dataclasses.InitVar[str] = '#ffd700'
    v_width: dataclasses.InitVar[Union[int, float]] = 2
    h_color: dataclasses.InitVar[str] = '#ffd700'
    h_width: dataclasses.InitVar[Union[int, float]] = 2

    def __post_init__(self, v_color: str, v_width: Union[int, float], h_color: str, h_width: Union[int, float]) -> None:
        """コンストラクタの続き
        Parameters
        ----------
        v_color: str default='#ffd700'
            縦線の色
        v_width: Union[int, float] default=2
            縦線の幅
        h_color: str default='#ffd700'
            横線の色
        h_width: Union[int, float] default=2
            横線の幅
        """
        self.vertical_line = pg.InfiniteLine(angle=90, movable=False, pen=pg.mkPen(v_color, width=v_width))
        self.horizontal_line = pg.InfiniteLine(angle=0, movable=False, pen=pg.mkPen(h_color, width=h_width))
        # plotterに追加
        self.plotter.addItem(self.vertical_line, ignoreBounds=True)
        self.plotter.addItem(self.horizontal_line, ignoreBounds=True)

        self.proxy = pg.SignalProxy(self.plotter.scene().sigMouseMoved, rateLimit=60, slot=self.cursor_moved)

    def cursor_moved(self, window_coord) -> None:
        """カーソル位置に十字線を移動させる
         Parameters
         ----------
         window_coord: tuple
             画面のピクセル単位の座標, ex) (PyQt5.QtCore.QPointF(2.0, 44.0),)

         Notes
         ----------
         関数内変数の説明===================
         pos: PyQt5.QtCore.QPointF
             カーソルの画面内の座標 ex) PyQt5.QtCore.QPointF(2.0, 44.0)
         cursor_point: QtCore.QPointF
             カーソルのグラフ内の座標 ex) PyQt5.QtCore.QPointF(269.0678171506131, 0.695852534562212)
         =================================
         """
        pos = window_coord[0]
        if self.plotter.sceneBoundingRect().contains(pos):
            cursor_point = self.plotter.vb.mapSceneToView(pos)
            self.vertical_line.setPos(cursor_point.x())
            self.horizontal_line.setPos(cursor_point.y())

```