From 8c9ffb2c224409847ff3b278d4457574743f5b7e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 11 Mar 2022 14:40:17 -0500 Subject: [PATCH] Add for a `BarItems` to display a line on high uppx When a bars graphic is zoomed out enough you get a high uppx, datum units-per-pixel, and there is no point in drawing the 6-lines in each bar element-graphic if you can't see them on the screen/display device. Instead here we offer converting to a `FastAppendCurve` which traces the high-low outline and instead display that when it's impossible to see the details of bars - approximately when the uppx >= 2. There is also some draft-commented code in here for downsampling the outlines as zoom level increases but it's not fully working and should likely be factored out into a higher level api anyway. --- piker/ui/_ohlc.py | 209 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 159 insertions(+), 50 deletions(-) diff --git a/piker/ui/_ohlc.py b/piker/ui/_ohlc.py index 374acab2..f060eb6f 100644 --- a/piker/ui/_ohlc.py +++ b/piker/ui/_ohlc.py @@ -34,6 +34,8 @@ from PyQt5.QtCore import QLineF, QPointF from .._profile import pg_profile_enabled from ._style import hcolor from ..log import get_logger +from ._curve import FastAppendCurve +from ._compression import hl2mxmn if TYPE_CHECKING: from ._chart import LinkedSplits @@ -46,10 +48,12 @@ def _mk_lines_array( data: list, size: int, elements_step: int = 6, -) -> np.ndarray: - """Create an ndarray to hold lines graphics info. - """ +) -> np.ndarray: + ''' + Create an ndarray to hold lines graphics info. + + ''' return np.zeros_like( data, shape=(int(size), elements_step), @@ -107,10 +111,12 @@ def path_arrays_from_ohlc( data: np.ndarray, start: int64, bar_gap: float64 = 0.43, -) -> np.ndarray: - """Generate an array of lines objects from input ohlc data. - """ +) -> np.ndarray: + ''' + Generate an array of lines objects from input ohlc data. + + ''' size = int(data.shape[0] * 6) x = np.zeros( @@ -220,13 +226,12 @@ class BarItems(pg.GraphicsObject): # that mode? self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) - # not sure if this is actually impoving anything but figured it - # was worth a shot: - # self.path.reserve(int(100e3 * 6)) - - self.path = QtGui.QPainterPath() self._pi = plotitem + self.path = QtGui.QPainterPath() + # not sure if this is actually impoving anything but figured it + # was worth a shot: + self.path.reserve(int(100e3 * 6)) self._xrange: tuple[int, int] self._yrange: tuple[float, float] @@ -239,11 +244,15 @@ class BarItems(pg.GraphicsObject): self.start_index: int = 0 self.stop_index: int = 0 + # downsampler-line state self._in_ds: bool = False + self._ds_lines: dict[int, FastAppendCurve] = {} + self._ds_line: Optional[FastAppendCurve] = None + self._ds: int = 0 def draw_from_data( self, - data: np.ndarray, + ohlc: np.ndarray, start: int = 0, ) -> QtGui.QPainterPath: @@ -253,18 +262,18 @@ class BarItems(pg.GraphicsObject): This routine is usually only called to draw the initial history. ''' - hist, last = data[:-1], data[-1] + hist, last = ohlc[:-1], ohlc[-1] self.path = gen_qpath(hist, start, self.w) # save graphics for later reference and keep track # of current internal "last index" - # self.start_index = len(data) - index = data['index'] + # self.start_index = len(ohlc) + index = ohlc['index'] self._xrange = (index[0], index[-1]) self._yrange = ( - np.nanmax(data['high']), - np.nanmin(data['low']), + np.nanmax(ohlc['high']), + np.nanmin(ohlc['low']), ) # up to last to avoid double draw of last bar @@ -274,23 +283,86 @@ class BarItems(pg.GraphicsObject): # https://doc.qt.io/qt-5/qgraphicsitem.html#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.update_ds_line(ohlc) + assert self._ds_line self._ds_line.hide() return self.path + def get_ds_line( + self, + ds: Optional[int] = None, + + ) -> tuple[FastAppendCurve, int]: + + if ds is None: + px_vecs = self.pixelVectors()[0] + if px_vecs: + xs_in_px = px_vecs.x() + ds = round(xs_in_px) + else: + ds = 0 + # print(f'ds is {ds}') + + return self._ds_lines.get(ds), ds + + def update_ds_line( + self, + ohlc: np.ndarray, + use_ds: bool = False, + + ) -> int: + + if not use_ds: + ds = 0 + else: + ds = None + + # determine current potential downsampling value (based on pixel + # scaling) and return any existing curve for it. + curve, ds = self.get_ds_line(ds=ds) + + # curve = self._ds_lines.get(ds) + # if current and current != curve: + # current.hide() + + # if no curve for this downsample rate yet, allowcate a new one + if not curve: + mxmn, x = hl2mxmn(ohlc, downsample_by=ds) + curve = FastAppendCurve( + y=mxmn, + x=x, + name='ohlc_ds_line', + color=self._color, + # color='dad_blue', + # use_polyline=True, # pretty sure this is slower? + ) + # self._pi.addItem(curve) + self._ds_lines[ds] = curve + self._ds_line = curve + + # elif ds != self._ds: + # print(f'ds changed {self._ds} -> {ds}') + + # TODO: we should be diffing the amount of new data which + # needs to be downsampled. Ideally we actually are just + # doing all the ds-ing in sibling actors so that the data + # can just be read and rendered to graphics on events of our + # choice. + # diff = do_diff(ohlc, new_bit) + mxmn, x = hl2mxmn(ohlc, downsample_by=ds) + + curve.update_from_array( + y=mxmn, + x=x, + ) + self._ds = ds + + return curve, ds + def update_from_array( self, - array: np.ndarray, + ohlc: np.ndarray, just_history=False, ) -> None: @@ -309,19 +381,16 @@ class BarItems(pg.GraphicsObject): ''' # XXX: always do this? if self._in_ds: - self._ds_line.update_from_array( - x=array['index'], - y=array['close'], - ) + curve, ds = self.update_ds_line(ohlc) return # index = self.start_index istart, istop = self._xrange - index = array['index'] + index = ohlc['index'] first_index, last_index = index[0], index[-1] - # length = len(array) + # length = len(ohlc) prepend_length = istart - first_index append_length = last_index - istop @@ -333,12 +402,12 @@ class BarItems(pg.GraphicsObject): if prepend_length: # new history was added and we need to render a new path - new_bars = array[:prepend_length] + new_bars = ohlc[:prepend_length] prepend_path = gen_qpath(new_bars, 0, self.w) # XXX: SOMETHING IS MAYBE FISHY HERE what with the old_path # y value not matching the first value from - # array[prepend_length + 1] ??? + # ohlc[prepend_length + 1] ??? # update path old_path = self.path @@ -350,14 +419,14 @@ class BarItems(pg.GraphicsObject): if append_length: # generate new lines objects for updatable "current bar" - self._last_bar_lines = lines_from_ohlc(array[-1], self.w) + self._last_bar_lines = lines_from_ohlc(ohlc[-1], self.w) # generate new graphics to match provided array # path appending logic: # we need to get the previous "current bar(s)" for the time step # and convert it to a sub-path to append to the historical set - # new_bars = array[istop - 1:istop + append_length - 1] - new_bars = array[-append_length - 1:-1] + # new_bars = ohlc[istop - 1:istop + append_length - 1] + new_bars = ohlc[-append_length - 1:-1] append_path = gen_qpath(new_bars, 0, self.w) self.path.moveTo(float(istop - self.w), float(new_bars[0]['open'])) self.path.addPath(append_path) @@ -370,7 +439,7 @@ class BarItems(pg.GraphicsObject): self._xrange = first_index, last_index # last bar update - i, o, h, l, last, v = array[-1][ + i, o, h, l, last, v = ohlc[-1][ ['index', 'open', 'high', 'low', 'close', 'volume'] ] # assert i == self.start_index - 1 @@ -462,36 +531,76 @@ class BarItems(pg.GraphicsObject): ''' # 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() + xvec = self.pixelVectors()[0] + if xvec: + xs_in_px = xvec.x() + else: + xs_in_px = self._ds_line.pixelVectors()[0].x() + + linked = self.linked + if ( not self._in_ds and xs_in_px >= x_gt ): - linked = self.linked # TODO: a `.ui()` log level? log.info(f'downsampling to line graphic {linked.symbol.key}') - self._in_ds = True self.hide() - self._pi.addItem(self._ds_line) - self._ds_line.show() - self._ds_line.update() + # XXX: is this actually any faster? + self._pi.removeItem(self) + + curve, ds = self.get_ds_line(ds=0) + last_curve = self._ds_line + assert last_curve is curve + self._pi.addItem(curve) + curve.show() + curve.update() + linked.graphics_cycle() + self._in_ds = True return True elif ( self._in_ds and xs_in_px < x_gt ): - linked = self.linked log.info(f'showing bars graphic {linked.symbol.key}') - self._in_ds = False + + curve, ds = self.get_ds_line(ds=0) + last_curve = self._ds_line + assert last_curve is curve + curve.hide() + self._pi.removeItem(curve) + + # XXX: is this actually any faster? + self._pi.addItem(self) self.show() self.update() - self._ds_line.hide() - self._pi.removeItem(self._ds_line) + + self._in_ds = False linked.graphics_cycle() + return True + # elif ( + # self._in_ds + # and self._ds != ds + # ): + # # curve = self._ds_lines.get(ds) + # # assert self._ds_line is not curve + # if self._ds_line and self._ds_line is not curve: + # self._ds_line.hide() + + # if curve: + # # self._pi.removeItem(curve) + # curve.show() + # curve.update() + + # self._ds_line = curve + # self._ds = ds + # linked.graphics_cycle() + # return True + # no curve change return False