Finally fix our `SelectRect` for use with cursor..

Turns out using the `.setRect()` method was the main cause of the issue
(though still don't really understand how or why) and this instead
adopts verbatim the code from `pg.ViewBox.updateScaleBox()` which uses
a scaling transform to set the rect for the "zoom scale box" thingy.

Further add a shite ton more improvements and interface tweaks in
support of the new remote-annotation control msging subsys:
- re-impl `.set_scen_pos()` to expect `QGraphicsScene` coordinates (i.e.
  passed from the interaction loop and pass scene `QPointF`s from
  `ViewBox.mouseDragEvent()` using the `MouseDragEvent.scenePos()` and
  friends; this is required to properly use the transform setting
  approach to resize the select-rect as mentioned above.
- add `as_point()` converter to maybe-cast python `tuple[float, float]`
  inputs (prolly from IPC msgs) to equivalent `QPointF`s.
- add a ton more detailed Qt-obj-related typing throughout our deriv.
- call `.add_to_view()` from init so that wtv view is passed in during
  instantiation is always set as the `.vb` after creation.
- factor the (proxy widget) label creation into a new `.init_label()`
  so that both the `set_scen/view_pos()` methods can call it and just
  generally decouple rect-pos mods from label content mods.
distribute_dis
Tyler Goodlet 2023-12-22 11:47:31 -05:00
parent 31fa0b02f5
commit 69368f20c2
1 changed files with 201 additions and 52 deletions

View File

@ -21,7 +21,8 @@ Higher level annotation editors.
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from typing import ( from typing import (
TYPE_CHECKING Sequence,
TYPE_CHECKING,
) )
import pyqtgraph as pg import pyqtgraph as pg
@ -31,24 +32,36 @@ from pyqtgraph import (
QtCore, QtCore,
QtWidgets, QtWidgets,
) )
from PyQt5.QtCore import (
QPointF,
QRectF,
)
from PyQt5.QtGui import ( from PyQt5.QtGui import (
QColor, QColor,
QTransform,
) )
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QGraphicsProxyWidget,
QGraphicsScene,
QLabel, QLabel,
) )
from pyqtgraph import functions as fn from pyqtgraph import functions as fn
from PyQt5.QtCore import QPointF
import numpy as np import numpy as np
from piker.types import Struct from piker.types import Struct
from ._style import hcolor, _font from ._style import (
hcolor,
_font,
)
from ._lines import LevelLine from ._lines import LevelLine
from ..log import get_logger from ..log import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from ._chart import GodWidget from ._chart import (
GodWidget,
ChartPlotWidget,
)
from ._interaction import ChartView from ._interaction import ChartView
@ -66,7 +79,7 @@ class ArrowEditor(Struct):
uid: str, uid: str,
x: float, x: float,
y: float, y: float,
color='default', color: str = 'default',
pointing: str | None = None, pointing: str | None = None,
) -> pg.ArrowItem: ) -> pg.ArrowItem:
@ -252,27 +265,56 @@ class LineEditor(Struct):
return lines return lines
class SelectRect(QtWidgets.QGraphicsRectItem): def as_point(
pair: Sequence[float, float] | QPointF,
) -> list[QPointF, QPointF]:
'''
Case any input tuple of floats to a a list of `QPoint` objects
for use in Qt geometry routines.
'''
if isinstance(pair, QPointF):
return pair
return QPointF(pair[0], pair[1])
# TODO: maybe implement better, something something RectItemProxy??
# -[ ] dig into details of how proxy's work?
# https://doc.qt.io/qt-5/qgraphicsscene.html#addWidget
# -[ ] consider using `.addRect()` maybe?
class SelectRect(QtWidgets.QGraphicsRectItem):
'''
A data-view "selection rectangle": the most fundamental
geometry for annotating data views.
- https://doc.qt.io/qt-5/qgraphicsrectitem.html
- https://doc.qt.io/qt-6/qgraphicsrectitem.html
'''
def __init__( def __init__(
self, self,
viewbox: ViewBox, viewbox: ViewBox,
color: str = 'dad_blue', color: str | None = None,
) -> None: ) -> None:
super().__init__(0, 0, 1, 1) super().__init__(0, 0, 1, 1)
# self.rbScaleBox = QGraphicsRectItem(0, 0, 1, 1) # self.rbScaleBox = QGraphicsRectItem(0, 0, 1, 1)
self.vb: ViewBox = viewbox self.vb: ViewBox = viewbox
self._chart: 'ChartPlotWidget' = None # noqa
# override selection box color self._chart: ChartPlotWidget | None = None # noqa
# TODO: maybe allow this to be dynamic via a method?
#l override selection box color
color: str = color or 'dad_blue'
color = QColor(hcolor(color)) color = QColor(hcolor(color))
self.setPen(fn.mkPen(color, width=1)) self.setPen(fn.mkPen(color, width=1))
color.setAlpha(66) color.setAlpha(66)
self.setBrush(fn.mkBrush(color)) self.setBrush(fn.mkBrush(color))
self.setZValue(1e9) self.setZValue(1e9)
self.hide() self.hide()
self._label = None
label = self._label = QLabel() label = self._label = QLabel()
label.setTextFormat(0) # markdown label.setTextFormat(0) # markdown
@ -282,13 +324,15 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
QtCore.Qt.AlignLeft QtCore.Qt.AlignLeft
# | QtCore.Qt.AlignVCenter # | QtCore.Qt.AlignVCenter
) )
label.hide() # always right after init
# proxy is created after containing scene is initialized # proxy is created after containing scene is initialized
self._label_proxy = None self._label_proxy: QGraphicsProxyWidget | None = None
self._abs_top_right = None self._abs_top_right: Point | None = None
# TODO: "swing %" might be handy here (data's max/min # % change) # TODO: "swing %" might be handy here (data's max/min
self._contents = [ # # % change)?
self._contents: list[str] = [
'change: {pchng:.2f} %', 'change: {pchng:.2f} %',
'range: {rng:.2f}', 'range: {rng:.2f}',
'bars: {nbars}', 'bars: {nbars}',
@ -298,25 +342,30 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
'sigma: {std:.2f}', 'sigma: {std:.2f}',
] ]
self.add_to_view(viewbox)
def add_to_view( def add_to_view(
self, self,
view: ChartView, view: ChartView,
) -> None: ) -> None:
''' '''
Self-defined view hookup impl. Self-defined view hookup impl which will
also re-assign the internal ref.
''' '''
view.addItem( view.addItem(
self, self,
ignoreBounds=True, ignoreBounds=True,
) )
if self.vb is not view:
self.vb = view
@property @property
def chart(self) -> 'ChartPlotWidget': # noqa def chart(self) -> ChartPlotWidget: # noqa
return self._chart return self._chart
@chart.setter @chart.setter
def chart(self, chart: 'ChartPlotWidget') -> None: # noqa def chart(self, chart: ChartPlotWidget) -> None: # noqa
self._chart = chart self._chart = chart
chart.sigRangeChanged.connect(self.update_on_resize) chart.sigRangeChanged.connect(self.update_on_resize)
palette = self._label.palette() palette = self._label.palette()
@ -340,8 +389,10 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
def set_scen_pos( def set_scen_pos(
self, self,
p1: QPointF, scen_p1: QPointF,
p2: QPointF scen_p2: QPointF,
update_label: bool = True,
) -> None: ) -> None:
''' '''
@ -350,16 +401,50 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
match. match.
''' '''
# map to view coords # NOTE XXX: apparently just setting it doesn't work!?
self.set_view_pos( # i have no idea why but it's pretty weird we have to do
self.vb.mapToView(p1), # this transform thing which was basically pulled verbatim
self.vb.mapToView(p2), # from the `pg.ViewBox.updateScaleBox()` method.
view_rect: QRectF = self.vb.childGroup.mapRectFromScene(
QRectF(
scen_p1,
scen_p2,
)
) )
self.setPos(view_rect.topLeft())
# XXX: does not work..!?!?
# https://doc.qt.io/qt-5/qgraphicsrectitem.html#setRect
# self.setRect(view_rect)
tr = QTransform.fromScale(
view_rect.width(),
view_rect.height(),
)
self.setTransform(tr)
# XXX: never got this working, was always offset
# / transformed completely wrong (and off to the far right
# from the cursor?)
# self.set_view_pos(
# view_rect=view_rect,
# # self.vwqpToView(p1),
# # self.vb.mapToView(p2),
# # start_pos=self.vb.mapToScene(p1),
# # end_pos=self.vb.mapToScene(p2),
# )
self.show()
if update_label:
self.init_label(view_rect)
def set_view_pos( def set_view_pos(
self, self,
start_pos: QPointF,
end_pos: QPointF, start_pos: QPointF | Sequence[float, float] | None = None,
end_pos: QPointF | Sequence[float, float] | None = None,
view_rect: QRectF | None = None,
update_label: bool = True,
) -> None: ) -> None:
''' '''
@ -368,27 +453,80 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
moved to match). moved to match).
''' '''
# https://doc.qt.io/qt-5/qgraphicsproxywidget.html if self._chart is None:
if self._label_proxy is None: raise RuntimeError(
self._label_proxy = self.vb.scene( 'You MUST assign a `SelectRect.chart: ChartPlotWidget`!'
).addWidget(self._label) )
# map to view coords and update area if view_rect is None:
r = QtCore.QRectF(start_pos, end_pos) # ensure point casting
start_pos: QPointF = as_point(start_pos)
end_pos: QPointF = as_point(end_pos)
# old way; don't need right? # map to view coords and update area
# lr = QtCore.QRectF(p1, p2) view_rect = QtCore.QRectF(
# r = self.vb.childGroup.mapRectFromParent(lr) start_pos,
end_pos,
)
self.setPos(r.topLeft()) self.setPos(view_rect.topLeft())
self.resetTransform()
self.setRect(r) # NOTE: SERIOUSLY NO IDEA WHY THIS WORKS...
# but it does and all the other commented stuff above
# dint, dawg..
# self.resetTransform()
# self.setRect(view_rect)
tr = QTransform.fromScale(
view_rect.width(),
view_rect.height(),
)
self.setTransform(tr)
if update_label:
self.init_label(view_rect)
print(
'SelectRect modify:\n'
f'QRectF: {view_rect}\n'
f'start_pos: {start_pos}\n'
f'end_pos: {end_pos}\n'
)
self.show() self.show()
y1, y2 = start_pos.y(), end_pos.y() def init_label(
x1, x2 = start_pos.x(), end_pos.x() self,
view_rect: QRectF,
) -> QLabel:
# TODO: heh, could probably use a max-min streamin algo here too # should be init-ed in `.__init__()`
label: QLabel = self._label
cv: ChartView = self.vb
# https://doc.qt.io/qt-5/qgraphicsproxywidget.html
if self._label_proxy is None:
scen: QGraphicsScene = cv.scene()
# NOTE: specifically this is passing a widget
# pointer to the scene's `.addWidget()` as per,
# https://doc.qt.io/qt-5/qgraphicsproxywidget.html#embedding-a-widget-with-qgraphicsproxywidget
self._label_proxy: QGraphicsProxyWidget = scen.addWidget(label)
# get label startup coords
tl: QPointF = view_rect.topLeft()
br: QPointF = view_rect.bottomRight()
x1, y1 = tl.x(), tl.y()
x2, y2 = br.x(), br.y()
# TODO: to remove, previous label corner point unpacking
# x1, y1 = start_pos.x(), start_pos.y()
# x2, y2 = end_pos.x(), end_pos.y()
# y1, y2 = start_pos.y(), end_pos.y()
# x1, x2 = start_pos.x(), end_pos.x()
# TODO: heh, could probably use a max-min streamin algo
# here too?
_, xmn = min(y1, y2), min(x1, x2) _, xmn = min(y1, y2), min(x1, x2)
ymx, xmx = max(y1, y2), max(x1, x2) ymx, xmx = max(y1, y2), max(x1, x2)
@ -398,26 +536,35 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
ixmn, ixmx = round(xmn), round(xmx) ixmn, ixmx = round(xmn), round(xmx)
nbars = ixmx - ixmn + 1 nbars = ixmx - ixmn + 1
chart = self._chart chart: ChartPlotWidget = self._chart
data = chart.get_viz(chart.name).shm.array[ixmn:ixmx] data: np.ndarray = chart.get_viz(
chart.name
).shm.array[ixmn:ixmx]
if len(data): if len(data):
std = data['close'].std() std: float = data['close'].std()
dmx = data['high'].max() dmx: float = data['high'].max()
dmn = data['low'].min() dmn: float = data['low'].min()
else: else:
dmn = dmx = std = np.nan dmn = dmx = std = np.nan
# update label info # update label info
self._label.setText('\n'.join(self._contents).format( label.setText('\n'.join(self._contents).format(
pchng=pchng, rng=rng, nbars=nbars, pchng=pchng,
std=std, dmx=dmx, dmn=dmn, rng=rng,
nbars=nbars,
std=std,
dmx=dmx,
dmn=dmn,
)) ))
# print(f'x2, y2: {(x2, y2)}') # print(f'x2, y2: {(x2, y2)}')
# print(f'xmn, ymn: {(xmn, ymx)}') # print(f'xmn, ymn: {(xmn, ymx)}')
label_anchor = Point(xmx + 2, ymx) label_anchor = Point(
xmx + 2,
ymx,
)
# XXX: in the drag bottom-right -> top-left case we don't # XXX: in the drag bottom-right -> top-left case we don't
# want the label to overlay the box. # want the label to overlay the box.
@ -426,9 +573,11 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
# # label_anchor = Point(x2, y2 + self._label.height()) # # label_anchor = Point(x2, y2 + self._label.height())
# label_anchor = Point(xmn, ymn) # label_anchor = Point(xmn, ymn)
self._abs_top_right = label_anchor self._abs_top_right: Point = label_anchor
self._label_proxy.setPos(self.vb.mapFromView(label_anchor)) self._label_proxy.setPos(
# self._label.show() cv.mapFromView(label_anchor)
)
label.show()
def clear(self): def clear(self):
''' '''