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()`
incremental_update_paths
Tyler Goodlet 2022-06-03 13:55:34 -04:00
parent 55772efb34
commit a66934a49d
4 changed files with 264 additions and 229 deletions

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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?