# piker: trading gear for hackers # Copyright (C) Tyler Goodlet (in stewardship for piker0) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """ Higher level annotation editors. """ from __future__ import annotations from collections import defaultdict from typing import ( Sequence, TYPE_CHECKING, ) import pyqtgraph as pg from pyqtgraph import ( ViewBox, Point, QtCore, QtWidgets, ) from pyqtgraph import functions as fn import numpy as np from piker.types import Struct from piker.ui.qt import ( Qt, QPointF, QRectF, QGraphicsProxyWidget, QGraphicsScene, QLabel, QColor, QTransform, ) from ._style import ( hcolor, _font, ) from ._lines import LevelLine from ..log import get_logger if TYPE_CHECKING: from ._chart import ( GodWidget, ChartPlotWidget, ) from ._interaction import ChartView log = get_logger(__name__) class ArrowEditor(Struct): godw: GodWidget = None # type: ignore # noqa _arrows: dict[str, list[pg.ArrowItem]] = {} def add( self, plot: pg.PlotItem, uid: str, x: float, y: float, color: str = 'default', pointing: str | None = None, ) -> pg.ArrowItem: ''' Add an arrow graphic to view at given (x, y). ''' angle = { 'up': 90, 'down': -90, None: 180, # pointing to right (as in an alert) }[pointing] # scale arrow sizing to dpi-aware font size = _font.font.pixelSize() * 0.8 arrow = pg.ArrowItem( angle=angle, baseAngle=0, headLen=size, headWidth=size/2, tailLen=None, pxMode=True, # coloring pen=pg.mkPen(hcolor('papas_special')), brush=pg.mkBrush(hcolor(color)), ) arrow.setPos(x, y) self._arrows.setdefault(uid, []).append(arrow) # render to view plot.addItem(arrow) return arrow def remove(self, arrow) -> bool: for linked in self.godw.iter_linked(): linked.chart.plotItem.removeItem(arrow) class LineEditor(Struct): ''' The great editor of linez. ''' godw: GodWidget = None # type: ignore # noqa _order_lines: defaultdict[str, LevelLine] = defaultdict(list) _active_staged_line: LevelLine = None def stage_line( self, line: LevelLine, ) -> LevelLine: ''' Stage a line at the current chart's cursor position and return it. ''' # add a "staged" cursor-tracking line to view # and cash it in a a var if self._active_staged_line: self.unstage_line() self._active_staged_line = line return line def unstage_line(self) -> LevelLine: ''' Inverse of ``.stage_line()``. ''' cursor = self.godw.get_cursor() if not cursor: return None # delete "staged" cursor tracking line from view line = self._active_staged_line if line: try: cursor._trackers.remove(line) except KeyError: # when the current cursor doesn't have said line # registered (probably means that user held order mode # key while panning to another view) then we just # ignore the remove error. pass line.delete() self._active_staged_line = None # show the crosshair y line and label cursor.show_xhair() def submit_lines( self, lines: list[LevelLine], uuid: str, ) -> LevelLine: # staged_line = self._active_staged_line # if not staged_line: # raise RuntimeError("No line is currently staged!?") # for now, until submission reponse arrives for line in lines: line.hide_labels() # register for later lookup/deletion self._order_lines[uuid] += lines return lines def commit_line(self, uuid: str) -> list[LevelLine]: ''' Commit a "staged line" to view. Submits the line graphic under the cursor as a (new) permanent graphic in view. ''' lines = self._order_lines[uuid] if lines: for line in lines: line.show_labels() line.hide_markers() log.debug(f'Level active for level: {line.value()}') # TODO: other flashy things to indicate the order is active return lines def lines_under_cursor(self) -> list[LevelLine]: ''' Get the line(s) under the cursor position. ''' # Delete any hoverable under the cursor return self.godw.get_cursor()._hovered def all_lines(self) -> list[LevelLine]: all_lines = [] for lines in list(self._order_lines.values()): all_lines.extend(lines) return all_lines def remove_line( self, line: LevelLine = None, uuid: str = None, ) -> LevelLine | None: '''Remove a line by refernce or uuid. If no lines or ids are provided remove all lines under the cursor position. ''' # try to look up line from our registry lines = self._order_lines.pop(uuid, None) if lines: cursor = self.godw.get_cursor() if cursor: for line in lines: # if hovered remove from cursor set hovered = cursor._hovered if line in hovered: hovered.remove(line) log.debug(f'deleting {line} with oid: {uuid}') line.delete() # make sure the xhair doesn't get left off # just because we never got a un-hover event cursor.show_xhair() else: log.warning(f'Could not find line for {line}') return lines 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__( self, viewbox: ViewBox, color: str | None = None, ) -> None: super().__init__(0, 0, 1, 1) # self.rbScaleBox = QGraphicsRectItem(0, 0, 1, 1) self.vb: ViewBox = viewbox 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)) self.setPen(fn.mkPen(color, width=1)) color.setAlpha(66) self.setBrush(fn.mkBrush(color)) self.setZValue(1e9) label = self._label = QLabel() label.setTextFormat( Qt.TextFormat.MarkdownText ) label.setFont(_font.font) label.setMargin(0) label.setAlignment( QtCore.Qt.AlignLeft # | QtCore.Qt.AlignVCenter ) label.hide() # always right after init # proxy is created after containing scene is initialized self._label_proxy: QGraphicsProxyWidget | None = None self._abs_top_right: Point | None = None # TODO: "swing %" might be handy here (data's max/min # # % change)? self._contents: list[str] = [ 'change: {pchng:.2f} %', 'range: {rng:.2f}', 'bars: {nbars}', 'max: {dmx}', 'min: {dmn}', # 'time: {nbars}m', # TODO: compute this per bar size 'sigma: {std:.2f}', ] self.add_to_view(viewbox) self.hide() def add_to_view( self, view: ChartView, ) -> None: ''' Self-defined view hookup impl which will also re-assign the internal ref. ''' view.addItem( self, ignoreBounds=True, ) if self.vb is not view: self.vb = view @property def chart(self) -> ChartPlotWidget: # noqa return self._chart @chart.setter def chart(self, chart: ChartPlotWidget) -> None: # noqa self._chart = chart chart.sigRangeChanged.connect(self.update_on_resize) palette = self._label.palette() # TODO: get bg color working palette.setColor( self._label.backgroundRole(), # QColor(chart.backgroundBrush()), QColor(hcolor('papas_special')), ) def update_on_resize(self, vr, r): ''' Re-position measure label on view range change. ''' if self._abs_top_right: self._label_proxy.setPos( self.vb.mapFromView(self._abs_top_right) ) def set_scen_pos( self, scen_p1: QPointF, scen_p2: QPointF, update_label: bool = True, ) -> None: ''' Set position from scene coords of selection rect (normally from mouse position) and accompanying label, move label to match. ''' # NOTE XXX: apparently just setting it doesn't work!? # i have no idea why but it's pretty weird we have to do # this transform thing which was basically pulled verbatim # 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( self, 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: ''' Set position from `ViewBox` coords (i.e. from the actual data domain) of rect (and any accompanying label which is moved to match). ''' if self._chart is None: raise RuntimeError( 'You MUST assign a `SelectRect.chart: ChartPlotWidget`!' ) if view_rect is None: # ensure point casting start_pos: QPointF = as_point(start_pos) end_pos: QPointF = as_point(end_pos) # map to view coords and update area view_rect = QtCore.QRectF( start_pos, end_pos, ) self.setPos(view_rect.topLeft()) # 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() def init_label( self, view_rect: QRectF, ) -> QLabel: # 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) ymx, xmx = max(y1, y2), max(x1, x2) pchng = (y2 - y1) / y1 * 100 rng = abs(y1 - y2) ixmn, ixmx = round(xmn), round(xmx) nbars = ixmx - ixmn + 1 chart: ChartPlotWidget = self._chart data: np.ndarray = chart.get_viz( chart.name ).shm.array[ixmn:ixmx] if len(data): std: float = data['close'].std() dmx: float = data['high'].max() dmn: float = data['low'].min() else: dmn = dmx = std = np.nan # update label info label.setText('\n'.join(self._contents).format( pchng=pchng, rng=rng, nbars=nbars, std=std, dmx=dmx, dmn=dmn, )) # print(f'x2, y2: {(x2, y2)}') # print(f'xmn, ymn: {(xmn, ymx)}') label_anchor = Point( xmx + 2, ymx, ) # XXX: in the drag bottom-right -> top-left case we don't # want the label to overlay the box. # if (x2, y2) == (xmn, ymx): # # could do this too but needs to be added after coords transform # # label_anchor = Point(x2, y2 + self._label.height()) # label_anchor = Point(xmn, ymn) self._abs_top_right: Point = label_anchor self._label_proxy.setPos( cv.mapFromView(label_anchor) ) label.show() def hide(self): ''' Clear the selection box from its graphics scene but don't delete it permanently. ''' super().hide() self._label.hide() # TODO: ensure noone else using dis. clear = hide def delete(self) -> None: ''' De-allocate this rect from its rendering graphics scene. Like a permanent hide. ''' scen: QGraphicsScene = self.scene() if scen is None: return scen.removeItem(self) if ( self._label and self._label_proxy ): scen.removeItem(self._label_proxy)