diff --git a/piker/ui/_curve.py b/piker/ui/_curve.py index 6b38420a..161332d5 100644 --- a/piker/ui/_curve.py +++ b/piker/ui/_curve.py @@ -18,25 +18,118 @@ Fast, smooth, sexy curves. """ -from typing import Tuple +from typing import Optional +import numpy as np import pyqtgraph as pg -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt5 import QtGui, QtWidgets +from PyQt5.QtCore import ( + QLineF, + QSizeF, + QRectF, + QPointF, +) from .._profile import pg_profile_enabled +from ._style import hcolor + + +def step_path_arrays_from_1d( + x: np.ndarray, + y: np.ndarray, + include_endpoints: bool = False, + +) -> (np.ndarray, np.ndarray): + ''' + Generate a "step mode" curve aligned with OHLC style bars + such that each segment spans each bar (aka "centered" style). + + ''' + y_out = y.copy() + x_out = x.copy() + x2 = np.empty( + # the data + 2 endpoints on either end for + # "termination of the path". + (len(x) + 1, 2), + # we want to align with OHLC or other sampling style + # bars likely so we need fractinal values + dtype=float, + ) + x2[0] = x[0] - 0.5 + x2[1] = x[0] + 0.5 + x2[1:] = x[:, np.newaxis] + 0.5 + + # flatten to 1-d + x_out = x2.reshape(x2.size) + + # we create a 1d with 2 extra indexes to + # hold the start and (current) end value for the steps + # on either end + y2 = np.empty((len(y), 2), dtype=y.dtype) + y2[:] = y[:, np.newaxis] + + y_out = np.empty( + 2*len(y) + 2, + dtype=y.dtype + ) + + # flatten and set 0 endpoints + y_out[1:-1] = y2.reshape(y2.size) + y_out[0] = 0 + y_out[-1] = 0 + + if not include_endpoints: + return x_out[:-1], y_out[:-1] + + else: + return x_out, y_out # TODO: got a feeling that dropping this inheritance gets us even more speedups class FastAppendCurve(pg.PlotCurveItem): + ''' + A faster, append friendly version of ``pyqtgraph.PlotCurveItem`` + built for real-time data updates. - def __init__(self, *args, **kwargs): + The main difference is avoiding regeneration of the entire + historical path where possible and instead only updating the "new" + segment(s) via a ``numpy`` array diff calc. Further the "last" + graphic segment is drawn independently such that near-term (high + frequency) discrete-time-sampled style updates don't trigger a full + path redraw. + + ''' + + def __init__( + self, + *args, + step_mode: bool = False, + color: str = 'default_lightest', + fill_color: Optional[str] = None, + + **kwargs + + ) -> None: # TODO: we can probably just dispense with the parent since # we're basically only using the pen setting now... super().__init__(*args, **kwargs) - self._last_line: QtCore.QLineF = None - self._xrange: Tuple[int, int] = self.dataBounds(ax=0) + self._xrange: tuple[int, int] = self.dataBounds(ax=0) + + # all history of curve is drawn in single px thickness + self.setPen(hcolor(color)) + + # last segment is drawn in 2px thickness for emphasis + self.last_step_pen = pg.mkPen(hcolor(color), width=2) + self._last_line: QLineF = None + self._last_step_rect: QRectF = None + + # flat-top style histogram-like discrete curve + self._step_mode: bool = step_mode + + # self._fill = True + self.setBrush(hcolor(fill_color or color)) # TODO: one question still remaining is if this makes trasform # interactions slower (such as zooming) and if so maybe if/when @@ -46,28 +139,46 @@ class FastAppendCurve(pg.PlotCurveItem): def update_from_array( self, - x, - y, + x: np.ndarray, + y: np.ndarray, + ) -> QtGui.QPainterPath: profiler = pg.debug.Profiler(disabled=not pg_profile_enabled()) flip_cache = False - # print(f"xrange: {self._xrange}") istart, istop = self._xrange + # print(f"xrange: {self._xrange}") + # compute the length diffs between the first/last index entry in + # the input data and the last indexes we have on record from the + # last time we updated the curve index. prepend_length = istart - x[0] append_length = x[-1] - istop - if self.path is None or prepend_length: + # step mode: draw flat top discrete "step" + # over the index space for each datum. + if self._step_mode: + x_out, y_out = step_path_arrays_from_1d(x[:-1], y[:-1]) + + else: + # by default we only pull data up to the last (current) index + x_out, y_out = x[:-1], y[:-1] + + if self.path is None or prepend_length > 0: self.path = pg.functions.arrayToQPath( - x[:-1], - y[:-1], - connect='all' + x_out, + y_out, + connect='all', + finiteCheck=False, ) profiler('generate fresh path') - # TODO: get this working - right now it's giving heck on vwap... + # if self._step_mode: + # self.path.closeSubpath() + + # TODO: get this piecewise prepend working - right now it's + # giving heck on vwap... # if prepend_length: # breakpoint() @@ -83,21 +194,56 @@ class FastAppendCurve(pg.PlotCurveItem): # # self.path.moveTo(new_x[0], new_y[0]) # self.path.connectPath(old_path) - if append_length: - # print(f"append_length: {append_length}") - new_x = x[-append_length - 2:-1] - new_y = y[-append_length - 2:-1] - # print((new_x, new_y)) + elif append_length > 0: + if self._step_mode: + new_x, new_y = step_path_arrays_from_1d( + x[-append_length - 2:-1], + y[-append_length - 2:-1], + ) + # [1:] since we don't need the vertical line normally at + # the beginning of the step curve taking the first (x, + # y) poing down to the x-axis **because** this is an + # appended path graphic. + new_x = new_x[1:] + new_y = new_y[1:] + + else: + # print(f"append_length: {append_length}") + new_x = x[-append_length - 2:-1] + new_y = y[-append_length - 2:-1] + # print((new_x, new_y)) append_path = pg.functions.arrayToQPath( new_x, new_y, - connect='all' + connect='all', + # finiteCheck=False, ) - # print(f"append_path br: {append_path.boundingRect()}") - # self.path.moveTo(new_x[0], new_y[0]) - # self.path.connectPath(append_path) - self.path.connectPath(append_path) + + path = self.path + + # other merging ideas: + # https://stackoverflow.com/questions/8936225/how-to-merge-qpainterpaths + if self._step_mode: + # path.addPath(append_path) + self.path.connectPath(append_path) + + # TODO: try out new work from `pyqtgraph` main which + # should repair horrid perf: + # https://github.com/pyqtgraph/pyqtgraph/pull/2032 + # ok, nope still horrible XD + # if self._fill: + # # XXX: super slow set "union" op + # self.path = self.path.united(append_path).simplified() + + # # path.addPath(append_path) + # # path.closeSubpath() + + else: + # print(f"append_path br: {append_path.boundingRect()}") + # self.path.moveTo(new_x[0], new_y[0]) + # self.path.connectPath(append_path) + path.connectPath(append_path) # XXX: pretty annoying but, without this there's little # artefacts on the append updates to the curve... @@ -112,8 +258,25 @@ class FastAppendCurve(pg.PlotCurveItem): self.xData = x self.yData = y - self._xrange = x[0], x[-1] - self._last_line = QtCore.QLineF(x[-2], y[-2], x[-1], y[-1]) + x0, x_last = self._xrange = x[0], x[-1] + y_last = y[-1] + + # draw the "current" step graphic segment so it lines up with + # the "middle" of the current (OHLC) sample. + if self._step_mode: + self._last_line = QLineF( + x_last - 0.5, 0, + x_last + 0.5, 0, + ) + self._last_step_rect = QRectF( + x_last - 0.5, 0, + x_last + 0.5, y_last + ) + else: + self._last_line = QLineF( + x[-2], y[-2], + x[-1], y_last + ) # trigger redraw of path # do update before reverting to cache mode @@ -121,6 +284,7 @@ class FastAppendCurve(pg.PlotCurveItem): self.update() if flip_cache: + # XXX: seems to be needed to avoid artifacts (see above). self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) def boundingRect(self): @@ -143,13 +307,13 @@ class FastAppendCurve(pg.PlotCurveItem): w = hb_size.width() + 1 h = hb_size.height() + 1 - br = QtCore.QRectF( + br = QRectF( # top left - QtCore.QPointF(hb.topLeft()), + QPointF(hb.topLeft()), # total size - QtCore.QSizeF(w, h) + QSizeF(w, h) ) # print(f'bounding rect: {br}') return br @@ -164,9 +328,28 @@ class FastAppendCurve(pg.PlotCurveItem): profiler = pg.debug.Profiler(disabled=not pg_profile_enabled()) # p.setRenderHint(p.Antialiasing, True) - p.setPen(self.opts['pen']) + if self._step_mode: + + brush = self.opts['brush'] + # p.drawLines(*tuple(filter(bool, self._last_step_lines))) + # p.drawRect(self._last_step_rect) + p.fillRect(self._last_step_rect, brush) + + # p.drawPath(self.path) + # profiler('.drawPath()') + + # else: + p.setPen(self.last_step_pen) p.drawLine(self._last_line) profiler('.drawLine()') + p.setPen(self.opts['pen']) p.drawPath(self.path) profiler('.drawPath()') + + # TODO: try out new work from `pyqtgraph` main which + # should repair horrid perf: + # https://github.com/pyqtgraph/pyqtgraph/pull/2032 + # if self._fill: + # brush = self.opts['brush'] + # p.fillPath(self.path, brush) diff --git a/piker/ui/_ohlc.py b/piker/ui/_ohlc.py index 7ae010fa..4331f036 100644 --- a/piker/ui/_ohlc.py +++ b/piker/ui/_ohlc.py @@ -146,7 +146,7 @@ def path_arrays_from_ohlc( # specifies that the first edge is never connected to the # prior bars last edge thus providing a small "gap"/"space" # between bars determined by ``bar_gap``. - c[istart:istop] = (0, 1, 1, 1, 1, 1) + c[istart:istop] = (1, 1, 1, 1, 1, 0) return x, y, c @@ -182,12 +182,14 @@ class BarItems(pg.GraphicsObject): # scene: 'QGraphicsScene', # noqa plotitem: 'pg.PlotItem', # noqa pen_color: str = 'bracket', + last_bar_color: str = 'bracket', ) -> None: super().__init__() - # XXX: for the mega-lulz increasing width here increases draw latency... - # so probably don't do it until we figure that out. + # XXX: for the mega-lulz increasing width here increases draw + # latency... so probably don't do it until we figure that out. self.bars_pen = pg.mkPen(hcolor(pen_color), width=1) + self.last_bar_pen = pg.mkPen(hcolor(last_bar_color), width=2) # NOTE: this prevents redraws on mouse interaction which is # a huge boon for avg interaction latency. @@ -354,30 +356,6 @@ class BarItems(pg.GraphicsObject): if flip_cache: self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) - def paint( - self, - p: QtGui.QPainter, - opt: QtWidgets.QStyleOptionGraphicsItem, - w: QtWidgets.QWidget - ) -> None: - - profiler = pg.debug.Profiler(disabled=not pg_profile_enabled()) - - # p.setCompositionMode(0) - p.setPen(self.bars_pen) - - # TODO: one thing we could try here is pictures being drawn of - # a fixed count of bars such that based on the viewbox indices we - # only draw the "rounded up" number of "pictures worth" of bars - # as is necesarry for what's in "view". Not sure if this will - # lead to any perf gains other then when zoomed in to less bars - # in view. - p.drawLines(*tuple(filter(bool, self._last_bar_lines))) - profiler('draw last bar') - - p.drawPath(self.path) - profiler('draw history path') - def boundingRect(self): # Qt docs: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect @@ -421,3 +399,28 @@ class BarItems(pg.GraphicsObject): ) ) + + def paint( + self, + p: QtGui.QPainter, + opt: QtWidgets.QStyleOptionGraphicsItem, + w: QtWidgets.QWidget + ) -> None: + + profiler = pg.debug.Profiler(disabled=not pg_profile_enabled()) + + # p.setCompositionMode(0) + + # TODO: one thing we could try here is pictures being drawn of + # a fixed count of bars such that based on the viewbox indices we + # only draw the "rounded up" number of "pictures worth" of bars + # as is necesarry for what's in "view". Not sure if this will + # lead to any perf gains other then when zoomed in to less bars + # in view. + p.setPen(self.last_bar_pen) + p.drawLines(*tuple(filter(bool, self._last_bar_lines))) + profiler('draw last bar') + + p.setPen(self.bars_pen) + p.drawPath(self.path) + profiler('draw history path') diff --git a/requirements.txt b/requirements.txt index 96f37cab..ca910e01 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,8 @@ # are often untested in tractor's CI and/or being tested by us # first before committing as core features in tractor's base. -e git+git://github.com/goodboy/tractor.git@piker_pin#egg=tractor + +# `pyqtgraph` peeps keep breaking, fixing, improving so might as well +# pin this to a dev branch that we have more control over especially +# as more graphics stuff gets hashed out. +-e git+git://github.com/pikers/pyqtgraph.git@piker_pin#egg=pyqtgraph