Add a downsampled line-curve support to `BarItems`

In effort to start getting some graphics speedups as detailed in #109,
this adds a `FastAppendCurve`to every `BarItems` as a `._ds_line` which
is only displayed (instead of the normal mult-line bars curve) when the
"width" of a bar is indistinguishable on screen from a line -> so once
the view coordinates map to > 2 pixels on the display device.
`BarItems.maybe_paint_line()` takes care of this scaling detection logic and is
called by the associated view's `.sigXRangeChanged` signal handler.
mkts_backup
Tyler Goodlet 2022-03-09 11:01:01 -05:00
parent 692e310a98
commit 28436bcb2b
2 changed files with 119 additions and 9 deletions

View File

@ -36,6 +36,7 @@ from ..log import get_logger
from ._style import _min_points_to_show from ._style import _min_points_to_show
from ._editors import SelectRect from ._editors import SelectRect
from . import _event from . import _event
from ._ohlc import BarItems
log = get_logger(__name__) log = get_logger(__name__)
@ -429,6 +430,12 @@ class ChartView(ViewBox):
def maxmin(self, callback: Callable) -> None: def maxmin(self, callback: Callable) -> None:
self._maxmin = callback self._maxmin = callback
def maybe_downsample_graphics(self):
for graphic in self._chart._graphics.values():
if isinstance(graphic, BarItems):
graphic.maybe_paint_line()
def wheelEvent( def wheelEvent(
self, self,
ev, ev,
@ -775,6 +782,15 @@ class ChartView(ViewBox):
''' '''
vb.sigXRangeChanged.connect(vb._set_yrange) vb.sigXRangeChanged.connect(vb._set_yrange)
# TODO: a smarter way to avoid calling this needlessly?
# 2 things i can think of:
# - register downsample-able graphics specially and only
# iterate those.
# - only register this when certain downsampleable graphics are
# "added to scene".
vb.sigXRangeChanged.connect(vb.maybe_downsample_graphics)
# mouse wheel doesn't emit XRangeChanged # mouse wheel doesn't emit XRangeChanged
vb.sigRangeChangedManually.connect(vb._set_yrange) vb.sigRangeChangedManually.connect(vb._set_yrange)
vb.sigResized.connect(vb._set_yrange) # splitter(s) resizing vb.sigResized.connect(vb._set_yrange) # splitter(s) resizing

View File

@ -29,6 +29,10 @@ from PyQt5.QtCore import QLineF, QPointF
from .._profile import pg_profile_enabled from .._profile import pg_profile_enabled
from ._style import hcolor from ._style import hcolor
from ..log import get_logger
log = get_logger(__name__)
def _mk_lines_array( def _mk_lines_array(
@ -170,8 +174,10 @@ def gen_qpath(
class BarItems(pg.GraphicsObject): class BarItems(pg.GraphicsObject):
"""Price range bars graphics rendered from a OHLC sequence. '''
""" "Price range" bars graphics rendered from a OHLC sampled sequence.
'''
sigPlotChanged = QtCore.pyqtSignal(object) sigPlotChanged = QtCore.pyqtSignal(object)
# 0.5 is no overlap between arms, 1.0 is full overlap # 0.5 is no overlap between arms, 1.0 is full overlap
@ -183,11 +189,15 @@ class BarItems(pg.GraphicsObject):
plotitem: 'pg.PlotItem', # noqa plotitem: 'pg.PlotItem', # noqa
pen_color: str = 'bracket', pen_color: str = 'bracket',
last_bar_color: str = 'bracket', last_bar_color: str = 'bracket',
) -> None:
super().__init__()
name: Optional[str] = None,
) -> None:
super().__init__()
# XXX: for the mega-lulz increasing width here increases draw # XXX: for the mega-lulz increasing width here increases draw
# latency... so probably don't do it until we figure that out. # latency... so probably don't do it until we figure that out.
self._color = pen_color
self.bars_pen = pg.mkPen(hcolor(pen_color), width=1) self.bars_pen = pg.mkPen(hcolor(pen_color), width=1)
self.last_bar_pen = pg.mkPen(hcolor(last_bar_color), width=2) self.last_bar_pen = pg.mkPen(hcolor(last_bar_color), width=2)
@ -219,15 +229,20 @@ class BarItems(pg.GraphicsObject):
self.start_index: int = 0 self.start_index: int = 0
self.stop_index: int = 0 self.stop_index: int = 0
self._in_ds: bool = False
def draw_from_data( def draw_from_data(
self, self,
data: np.ndarray, data: np.ndarray,
start: int = 0, start: int = 0,
) -> QtGui.QPainterPath: ) -> QtGui.QPainterPath:
"""Draw OHLC datum graphics from a ``np.ndarray``. '''
Draw OHLC datum graphics from a ``np.ndarray``.
This routine is usually only called to draw the initial history. This routine is usually only called to draw the initial history.
"""
'''
hist, last = data[:-1], data[-1] hist, last = data[:-1], data[-1]
self.path = gen_qpath(hist, start, self.w) self.path = gen_qpath(hist, start, self.w)
@ -249,14 +264,28 @@ class BarItems(pg.GraphicsObject):
# https://doc.qt.io/qt-5/qgraphicsitem.html#update # https://doc.qt.io/qt-5/qgraphicsitem.html#update
self.update() self.update()
from ._curve import FastAppendCurve
self._ds_line = FastAppendCurve(
y=data['close'],
x=data['index'],
name='ohlc_ds_line',
color=self._color,
# use_polyline=True, # pretty sure this is slower?
)
self.update_from_array(data)
self._pi.addItem(self._ds_line)
self._ds_line.hide()
return self.path return self.path
def update_from_array( def update_from_array(
self, self,
array: np.ndarray, array: np.ndarray,
just_history=False, just_history=False,
) -> None: ) -> None:
"""Update the last datum's bar graphic from input data array. '''
Update the last datum's bar graphic from input data array.
This routine should be interface compatible with This routine should be interface compatible with
``pg.PlotCurveItem.setData()``. Normally this method in ``pg.PlotCurveItem.setData()``. Normally this method in
@ -266,7 +295,16 @@ class BarItems(pg.GraphicsObject):
does) so this "should" be simpler and faster. does) so this "should" be simpler and faster.
This routine should be made (transitively) as fast as possible. This routine should be made (transitively) as fast as possible.
"""
'''
# XXX: always do this?
if self._in_ds:
self._ds_line.update_from_array(
x=array['index'],
y=array['close'],
)
return
# index = self.start_index # index = self.start_index
istart, istop = self._xrange istart, istop = self._xrange
@ -400,14 +438,59 @@ class BarItems(pg.GraphicsObject):
) )
def maybe_paint_line(
self,
x_gt: float = 2.,
) -> bool:
'''
Call this when you want to stop drawing individual
bars and instead use a ``FastAppendCurve`` intepolation
line (normally when the width of a bar (aka 1.0 in the x)
is less then a pixel width on the device).
'''
# this is the ``float`` value of the "number of x units" (in
# view coords) that a pixel spans.
xs_in_px = self.pixelVectors()[0].x()
if (
not self._in_ds
and xs_in_px >= x_gt
):
# TODO: a `.ui()` log level?
log.info(f'downsampling to line graphic')
self._in_ds = True
self.hide()
self._pi.addItem(self._ds_line)
self._ds_line.show()
return True
elif (
self._in_ds
and xs_in_px < x_gt
):
log.info(f'showing bars graphic')
self._in_ds = False
self.show()
self._ds_line.hide()
self._pi.removeItem(self._ds_line)
return False
def paint( def paint(
self, self,
p: QtGui.QPainter, p: QtGui.QPainter,
opt: QtWidgets.QStyleOptionGraphicsItem, opt: QtWidgets.QStyleOptionGraphicsItem,
w: QtWidgets.QWidget w: QtWidgets.QWidget
) -> None: ) -> None:
profiler = pg.debug.Profiler(disabled=not pg_profile_enabled()) if self._in_ds:
return
profiler = pg.debug.Profiler(
disabled=not pg_profile_enabled(),
delayed=False,
)
# p.setCompositionMode(0) # p.setCompositionMode(0)
@ -424,3 +507,14 @@ class BarItems(pg.GraphicsObject):
p.setPen(self.bars_pen) p.setPen(self.bars_pen)
p.drawPath(self.path) p.drawPath(self.path)
profiler('draw history path') profiler('draw history path')
profiler.finish()
# NOTE: for testing paint frequency as throttled by display loop.
# now = time.time()
# global _last_draw
# print(f'DRAW RATE {1/(now - _last_draw)}')
# _last_draw = now
# import time
# _last_draw: float = time.time()