From a66934a49d7eea6d368eb175ceee38a05f8cb6f6 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 3 Jun 2022 13:55:34 -0400 Subject: [PATCH] Add `Curve` sub-types with new custom graphics API Instead of using a bunch of internal logic to modify low level paint-able elements create a `Curve` lineage that allows for graphics "style" customization via a small set of public methods: - `Curve.declare_paintables()` to allow setup of state/elements to be drawn in later methods. - `.sub_paint()` to allow painting additional elements along with the defaults. - `.sub_br()` to customize the `.boundingRect()` dimensions. - `.draw_last_datum()` which is expected to produce the paintable elements which will show the last datum in view. Introduce the new sub-types and load as necessary in `ChartPlotWidget.draw_curve()`: - `FlattenedOHLC` - `StepCurve` Reimplement all `.draw_last()` routines as a `Curve` method and call it the same way from `Flow.update_graphics()` --- piker/ui/_chart.py | 33 +++---- piker/ui/_curve.py | 212 +++++++++++++++++++++++++++++++++++++-------- piker/ui/_flows.py | 198 ++++++------------------------------------ piker/ui/_ohlc.py | 50 ++++++++++- 4 files changed, 264 insertions(+), 229 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index bbab5a41..7b40f0d7 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -50,7 +50,10 @@ from ._cursor import ( from ..data._sharedmem import ShmArray from ._l1 import L1Labels from ._ohlc import BarItems -from ._curve import Curve +from ._curve import ( + Curve, + StepCurve, +) from ._style import ( hcolor, CHART_MARGINS, @@ -1051,6 +1054,7 @@ class ChartPlotWidget(pg.PlotWidget): color: Optional[str] = None, add_label: bool = True, pi: Optional[pg.PlotItem] = None, + step_mode: bool = False, **pdi_kwargs, @@ -1067,29 +1071,18 @@ class ChartPlotWidget(pg.PlotWidget): data_key = array_key or name - # yah, we wrote our own B) - data = shm.array - curve = Curve( - # antialias=True, + curve_type = { + None: Curve, + 'step': StepCurve, + # TODO: + # 'bars': BarsItems + }['step' if step_mode else None] + + curve = curve_type( name=name, - - # XXX: pretty sure this is just more overhead - # on data reads and makes graphics rendering no faster - # clipToView=True, - **pdi_kwargs, ) - # XXX: see explanation for different caching modes: - # https://stackoverflow.com/a/39410081 - # seems to only be useful if we don't re-generate the entire - # QPainterPath every time - # curve.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) - - # don't ever use this - it's a colossal nightmare of artefacts - # and is disastrous for performance. - # curve.setCacheMode(QtWidgets.QGraphicsItem.ItemCoordinateCache) - pi = pi or self.plotItem self._flows[data_key] = Flow( diff --git a/piker/ui/_curve.py b/piker/ui/_curve.py index 2cf9f0b7..8feb24b9 100644 --- a/piker/ui/_curve.py +++ b/piker/ui/_curve.py @@ -19,20 +19,24 @@ Fast, smooth, sexy curves. """ from contextlib import contextmanager as cm -from typing import Optional +from typing import Optional, Callable import numpy as np import pyqtgraph as pg -from PyQt5 import QtGui, QtWidgets +from PyQt5 import QtWidgets from PyQt5.QtWidgets import QGraphicsItem from PyQt5.QtCore import ( Qt, QLineF, QSizeF, QRectF, + # QRect, QPointF, ) - +from PyQt5.QtGui import ( + QPainter, + QPainterPath, +) from .._profile import pg_profile_enabled, ms_slower_then from ._style import hcolor # from ._compression import ( @@ -59,10 +63,12 @@ class Curve(pg.GraphicsObject): ``pyqtgraph.PlotCurveItem`` built for highly customizable real-time updates. - This type is a much stripped down version of a ``pyqtgraph`` style "graphics object" in - the sense that the internal lower level graphics which are drawn in the ``.paint()`` method - are actually rendered outside of this class entirely and instead are assigned as state - (instance vars) here and then drawn during a Qt graphics cycle. + This type is a much stripped down version of a ``pyqtgraph`` style + "graphics object" in the sense that the internal lower level + graphics which are drawn in the ``.paint()`` method are actually + rendered outside of this class entirely and instead are assigned as + state (instance vars) here and then drawn during a Qt graphics + cycle. The main motivation for this more modular, composed design is that lower level graphics data can be rendered in different threads and @@ -72,13 +78,20 @@ class Curve(pg.GraphicsObject): level path generation and incremental update. The main differences in the path generation code include: - - avoiding regeneration of the entire historical path where possible and instead - only updating the "new" segment(s) via a ``numpy`` array diff calc. + - avoiding regeneration of the entire historical path where possible + and instead only updating the "new" segment(s) via a ``numpy`` + array diff calc. - here, the "last" graphics datum-segment is drawn independently such that near-term (high frequency) discrete-time-sampled style updates don't trigger a full path redraw. ''' + + # sub-type customization methods + sub_br: Optional[Callable] = None + sub_paint: Optional[Callable] = None + declare_paintables: Optional[Callable] = None + def __init__( self, *args, @@ -94,19 +107,20 @@ class Curve(pg.GraphicsObject): ) -> None: + self._name = name + # brutaaalll, see comments within.. self.yData = None self.xData = None - self._last_cap: int = 0 - self._name = name - self.path: Optional[QtGui.QPainterPath] = None + # self._last_cap: int = 0 + self.path: Optional[QPainterPath] = None # additional path used for appends which tries to avoid # triggering an update/redraw of the presumably larger # historical ``.path`` above. self.use_fpath = use_fpath - self.fast_path: Optional[QtGui.QPainterPath] = None + self.fast_path: Optional[QPainterPath] = None # TODO: we can probably just dispense with the parent since # we're basically only using the pen setting now... @@ -125,12 +139,12 @@ class Curve(pg.GraphicsObject): # self.last_step_pen = pg.mkPen(hcolor(color), width=2) self.last_step_pen = pg.mkPen(pen, width=2) - self._last_line: Optional[QLineF] = None - self._last_step_rect: Optional[QRectF] = None + # self._last_line: Optional[QLineF] = None + self._last_line = QLineF() self._last_w: float = 1 # flat-top style histogram-like discrete curve - self._step_mode: bool = step_mode + # self._step_mode: bool = step_mode # self._fill = True self._brush = pg.functions.mkBrush(hcolor(fill_color or color)) @@ -148,6 +162,21 @@ class Curve(pg.GraphicsObject): # endpoint (something we saw on trade rate curves) self.setCacheMode(QGraphicsItem.DeviceCoordinateCache) + # XXX: see explanation for different caching modes: + # https://stackoverflow.com/a/39410081 + # seems to only be useful if we don't re-generate the entire + # QPainterPath every time + # curve.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) + + # don't ever use this - it's a colossal nightmare of artefacts + # and is disastrous for performance. + # curve.setCacheMode(QtWidgets.QGraphicsItem.ItemCoordinateCache) + + # allow sub-type customization + declare = self.declare_paintables + if declare: + declare() + # TODO: probably stick this in a new parent # type which will contain our own version of # what ``PlotCurveItem`` had in terms of base @@ -215,7 +244,7 @@ class Curve(pg.GraphicsObject): Compute and then cache our rect. ''' if self.path is None: - return QtGui.QPainterPath().boundingRect() + return QPainterPath().boundingRect() else: # dynamically override this method after initial # path is created to avoid requiring the above None check @@ -227,14 +256,15 @@ class Curve(pg.GraphicsObject): Post init ``.boundingRect()```. ''' - hb = self.path.controlPointRect() # hb = self.path.boundingRect() + hb = self.path.controlPointRect() hb_size = hb.size() fp = self.fast_path if fp: fhb = fp.controlPointRect() hb_size = fhb.size() + hb_size + # print(f'hb_size: {hb_size}') # if self._last_step_rect: @@ -255,7 +285,13 @@ class Curve(pg.GraphicsObject): w = hb_size.width() h = hb_size.height() - if not self._last_step_rect: + sbr = self.sub_br + if sbr: + w, h = self.sub_br(w, h) + else: + # assume plain line graphic and use + # default unit step in each direction. + # only on a plane line do we include # and extra index step's worth of width # since in the step case the end of the curve @@ -289,7 +325,7 @@ class Curve(pg.GraphicsObject): def paint( self, - p: QtGui.QPainter, + p: QPainter, opt: QtWidgets.QStyleOptionGraphicsItem, w: QtWidgets.QWidget @@ -301,25 +337,16 @@ class Curve(pg.GraphicsObject): ms_threshold=ms_slower_then, ) - if ( - self._step_mode - and self._last_step_rect - ): - brush = self._brush + sub_paint = self.sub_paint + if sub_paint: + sub_paint(p, profiler) - # p.drawLines(*tuple(filter(bool, self._last_step_lines))) - # p.drawRect(self._last_step_rect) - p.fillRect(self._last_step_rect, brush) - profiler('.fillRect()') - - if self._last_line: - p.setPen(self.last_step_pen) - p.drawLine(self._last_line) - profiler('.drawLine()') - p.setPen(self._pen) + p.setPen(self.last_step_pen) + p.drawLine(self._last_line) + profiler('.drawLine()') + p.setPen(self._pen) path = self.path - # cap = path.capacity() # if cap != self._last_cap: # print(f'NEW CAPACITY: {self._last_cap} -> {cap}') @@ -341,3 +368,116 @@ class Curve(pg.GraphicsObject): # if self._fill: # brush = self.opts['brush'] # p.fillPath(self.path, brush) + + def draw_last_datum( + self, + path: QPainterPath, + src_data: np.ndarray, + render_data: np.ndarray, + reset: bool, + array_key: str, + + ) -> None: + # default line draw last call + with self.reset_cache(): + x = render_data['index'] + y = render_data[array_key] + + x_last = x[-1] + y_last = y[-1] + + # draw the "current" step graphic segment so it + # lines up with the "middle" of the current + # (OHLC) sample. + self._last_line = QLineF( + x[-2], y[-2], + x_last, y_last + ) + + +# TODO: this should probably be a "downsampled" curve type +# that draws a bar-style (but for the px column) last graphics +# element such that the current datum in view can be shown +# (via it's max / min) even when highly zoomed out. +class FlattenedOHLC(Curve): + + def draw_last_datum( + self, + path: QPainterPath, + src_data: np.ndarray, + render_data: np.ndarray, + reset: bool, + array_key: str, + + ) -> None: + lasts = src_data[-2:] + x = lasts['index'] + y = lasts['close'] + + # draw the "current" step graphic segment so it + # lines up with the "middle" of the current + # (OHLC) sample. + self._last_line = QLineF( + x[-2], y[-2], + x[-1], y[-1] + ) + + +class StepCurve(Curve): + + def declare_paintables( + self, + ) -> None: + self._last_step_rect = QRectF() + + def draw_last_datum( + self, + path: QPainterPath, + src_data: np.ndarray, + render_data: np.ndarray, + reset: bool, + array_key: str, + + w: float = 0.5, + + ) -> None: + + # TODO: remove this and instead place all step curve + # updating into pre-path data render callbacks. + # full input data + x = src_data['index'] + y = src_data[array_key] + + x_last = x[-1] + y_last = y[-1] + + # lol, commenting this makes step curves + # all "black" for me :eyeroll:.. + self._last_line = QLineF( + x_last - w, 0, + x_last + w, 0, + ) + self._last_step_rect = QRectF( + x_last - w, 0, + x_last + w, y_last, + ) + + def sub_paint( + self, + p: QPainter, + profiler: pg.debug.Profiler, + + ) -> None: + # p.drawLines(*tuple(filter(bool, self._last_step_lines))) + # p.drawRect(self._last_step_rect) + p.fillRect(self._last_step_rect, self._brush) + profiler('.fillRect()') + + def sub_br( + self, + path_w: float, + path_h: float, + + ) -> (float, float): + # passthrough + return path_w, path_h diff --git a/piker/ui/_flows.py b/piker/ui/_flows.py index c2e6ec09..7960d649 100644 --- a/piker/ui/_flows.py +++ b/piker/ui/_flows.py @@ -34,13 +34,6 @@ import numpy as np from numpy.lib import recfunctions as rfn import pyqtgraph as pg from PyQt5.QtGui import QPainterPath -from PyQt5.QtCore import ( - # Qt, - QLineF, - # QSizeF, - QRectF, - # QPointF, -) from ..data._sharedmem import ( ShmArray, @@ -57,10 +50,12 @@ from ._pathops import ( ) from ._ohlc import ( BarItems, - bar_from_ohlc_row, + # bar_from_ohlc_row, ) from ._curve import ( Curve, + StepCurve, + FlattenedOHLC, ) from ..log import get_logger @@ -175,7 +170,7 @@ def render_baritems( format_xy=ohlc_flat_to_xy, ) - curve = Curve( + curve = FlattenedOHLC( name=f'{flow.name}_ds_ohlc', color=bars._color, ) @@ -244,84 +239,10 @@ def render_baritems( bars.show() bars.update() - draw_last = False - - if should_line: - - def draw_last_flattened_ohlc_line( - graphics: pg.GraphicsObject, - path: QPainterPath, - src_data: np.ndarray, - render_data: np.ndarray, - reset: bool, - - ) -> None: - lasts = src_data[-2:] - x = lasts['index'] - y = lasts['close'] - - # draw the "current" step graphic segment so it - # lines up with the "middle" of the current - # (OHLC) sample. - graphics._last_line = QLineF( - x[-2], y[-2], - x[-1], y[-1] - ) - - draw_last = draw_last_flattened_ohlc_line - - else: - def draw_last_ohlc_bar( - graphics: pg.GraphicsObject, - path: QPainterPath, - src_data: np.ndarray, - render_data: np.ndarray, - reset: bool, - - ) -> None: - last = src_data[-1] - - # generate new lines objects for updatable "current bar" - graphics._last_bar_lines = bar_from_ohlc_row(last) - - # last bar update - i, o, h, l, last, v = last[ - ['index', 'open', 'high', 'low', 'close', 'volume'] - ] - # assert i == graphics.start_index - 1 - # assert i == last_index - body, larm, rarm = graphics._last_bar_lines - - # XXX: is there a faster way to modify this? - rarm.setLine(rarm.x1(), last, rarm.x2(), last) - - # writer is responsible for changing open on "first" volume of bar - larm.setLine(larm.x1(), o, larm.x2(), o) - - if l != h: # noqa - - if body is None: - body = graphics._last_bar_lines[0] = QLineF(i, l, i, h) - else: - # update body - body.setLine(i, l, i, h) - - # XXX: pretty sure this is causing an issue where the - # bar has a large upward move right before the next - # sample and the body is getting set to None since the - # next bar is flat but the shm array index update wasn't - # read by the time this code runs. Iow we're doing this - # removal of the body for a bar index that is now out of - # date / from some previous sample. It's weird though - # because i've seen it do this to bars i - 3 back? - - draw_last = draw_last_ohlc_bar - return ( graphics, r, {'read_from_key': False}, - draw_last, should_line, changed_to_line, ) @@ -411,10 +332,10 @@ class Flow(msgspec.Struct): # , frozen=True): ''' name: str plot: pg.PlotItem - graphics: pg.GraphicsObject + graphics: Curve _shm: ShmArray - draw_last_datum: Optional[ + draw_last: Optional[ Callable[ [np.ndarray, str], tuple[np.ndarray] @@ -597,12 +518,9 @@ class Flow(msgspec.Struct): # , frozen=True): render to graphics. ''' - - # profiler = profiler or pg.debug.Profiler( profiler = pg.debug.Profiler( msg=f'Flow.update_graphics() for {self.name}', disabled=not pg_profile_enabled(), - # disabled=False, ms_threshold=4, # ms_threshold=ms_slower_then, ) @@ -623,13 +541,9 @@ class Flow(msgspec.Struct): # , frozen=True): # print('exiting early') return graphics - draw_last: bool = True slice_to_head: int = -1 - should_redraw: bool = False - rkwargs = {} - bars = False if isinstance(graphics, BarItems): # XXX: special case where we change out graphics @@ -638,7 +552,6 @@ class Flow(msgspec.Struct): # , frozen=True): graphics, r, rkwargs, - draw_last, should_line, changed_to_line, ) = render_baritems( @@ -648,7 +561,7 @@ class Flow(msgspec.Struct): # , frozen=True): profiler, **kwargs, ) - bars = True + # bars = True should_redraw = changed_to_line or not should_line else: @@ -661,7 +574,7 @@ class Flow(msgspec.Struct): # , frozen=True): last_read=read, ) - # ``Curve`` case: + # ``Curve`` derivative case(s): array_key = array_key or self.name # print(array_key) @@ -670,20 +583,19 @@ class Flow(msgspec.Struct): # , frozen=True): should_ds: bool = r._in_ds showing_src_data: bool = not r._in_ds - step_mode = getattr(graphics, '_step_mode', False) + # step_mode = getattr(graphics, '_step_mode', False) + step_mode = isinstance(graphics, StepCurve) if step_mode: r.allocate_xy = to_step_format r.update_xy = update_step_xy r.format_xy = step_to_xy - slice_to_head = -2 - # TODO: append logic inside ``.render()`` isn't - # corrent yet for step curves.. remove this to see it. + # correct yet for step curves.. remove this to see it. should_redraw = True - - draw_last = True + # draw_last = True + slice_to_head = -2 # downsampling incremental state checking # check for and set std m4 downsample conditions @@ -760,77 +672,23 @@ class Flow(msgspec.Struct): # , frozen=True): graphics.path = r.path graphics.fast_path = r.fast_path - if draw_last and not bars: + graphics.draw_last_datum( + path, + src_array, + data, + reset, + array_key, + ) - if not step_mode: + # TODO: is this ever better? + # graphics.prepareGeometryChange() + # profiler('.prepareGeometryChange()') - def draw_last_line( - graphics: pg.GraphicsObject, - path: QPainterPath, - src_data: np.ndarray, - render_data: np.ndarray, - reset: bool, - - ) -> None: - # default line draw last call - with graphics.reset_cache(): - x = render_data['index'] - y = render_data[array_key] - x_last = x[-1] - y_last = y[-1] - - # draw the "current" step graphic segment so it - # lines up with the "middle" of the current - # (OHLC) sample. - graphics._last_line = QLineF( - x[-2], y[-2], - x_last, y_last - ) - - draw_last_line(graphics, path, src_array, data, reset) - - else: - - def draw_last_step( - graphics: pg.GraphicsObject, - path: QPainterPath, - src_data: np.ndarray, - render_data: np.ndarray, - reset: bool, - - ) -> None: - w = 0.5 - # TODO: remove this and instead place all step curve - # updating into pre-path data render callbacks. - # full input data - x = src_array['index'] - y = src_array[array_key] - x_last = x[-1] - y_last = y[-1] - - # lol, commenting this makes step curves - # all "black" for me :eyeroll:.. - graphics._last_line = QLineF( - x_last - w, 0, - x_last + w, 0, - ) - graphics._last_step_rect = QRectF( - x_last - w, 0, - x_last + w, y_last, - ) - - draw_last_step(graphics, path, src_array, data, reset) - - # TODO: does this actuallly help us in any way (prolly should - # look at the source / ask ogi). I think it avoid artifacts on - # wheel-scroll downsampling curve updates? - graphics.update() - profiler('.prepareGeometryChange()') - - elif bars and draw_last: - draw_last(graphics, path, src_array, data, reset) - graphics.update() - profiler('.update()') + # TODO: does this actuallly help us in any way (prolly should + # look at the source / ask ogi). I think it avoid artifacts on + # wheel-scroll downsampling curve updates? + graphics.update() + profiler('.update()') return graphics diff --git a/piker/ui/_ohlc.py b/piker/ui/_ohlc.py index d4a93065..0f7ce6f7 100644 --- a/piker/ui/_ohlc.py +++ b/piker/ui/_ohlc.py @@ -27,6 +27,7 @@ import numpy as np import pyqtgraph as pg from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import QLineF, QPointF +from PyQt5.QtGui import QPainterPath from .._profile import pg_profile_enabled, ms_slower_then from ._style import hcolor @@ -85,8 +86,6 @@ class BarItems(pg.GraphicsObject): "Price range" bars graphics rendered from a OHLC sampled sequence. ''' - sigPlotChanged = QtCore.pyqtSignal(object) - def __init__( self, linked: LinkedSplits, @@ -107,7 +106,7 @@ class BarItems(pg.GraphicsObject): self._name = name self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) - self.path = QtGui.QPainterPath() + self.path = QPainterPath() self._last_bar_lines: Optional[tuple[QLineF, ...]] = None def x_uppx(self) -> int: @@ -192,3 +191,48 @@ class BarItems(pg.GraphicsObject): p.setPen(self.bars_pen) p.drawPath(self.path) profiler(f'draw history path: {self.path.capacity()}') + + def draw_last_datum( + self, + path: QPainterPath, + src_data: np.ndarray, + render_data: np.ndarray, + reset: bool, + array_key: str, + + ) -> None: + last = src_data[-1] + + # generate new lines objects for updatable "current bar" + self._last_bar_lines = bar_from_ohlc_row(last) + + # last bar update + i, o, h, l, last, v = last[ + ['index', 'open', 'high', 'low', 'close', 'volume'] + ] + # assert i == graphics.start_index - 1 + # assert i == last_index + body, larm, rarm = self._last_bar_lines + + # XXX: is there a faster way to modify this? + rarm.setLine(rarm.x1(), last, rarm.x2(), last) + + # writer is responsible for changing open on "first" volume of bar + larm.setLine(larm.x1(), o, larm.x2(), o) + + if l != h: # noqa + + if body is None: + body = self._last_bar_lines[0] = QLineF(i, l, i, h) + else: + # update body + body.setLine(i, l, i, h) + + # XXX: pretty sure this is causing an issue where the + # bar has a large upward move right before the next + # sample and the body is getting set to None since the + # next bar is flat but the shm array index update wasn't + # read by the time this code runs. Iow we're doing this + # removal of the body for a bar index that is now out of + # date / from some previous sample. It's weird though + # because i've seen it do this to bars i - 3 back?