From b524ea5c22a3ac902b7f81a7634e3c0d457853e0 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 31 Oct 2022 09:37:32 -0400 Subject: [PATCH] Extract and fork `pyqtgraph` upstream submissions Fork out our patch set submitted to upstream in multiple PRs (since they aren't moving and/or aren't a priority to core) which can be seen in full from the following diff: https://github.com/pyqtgraph/pyqtgraph/compare/master...pikers:pyqtgraph:graphics_pin Move these type extensions into the internal `.ui._pg_overrides` module. The changes are related to both `pyqtgraph.PlotItem` and `.AxisItem` and were driven for our need for multi-view overlays (overlaid charts with optionally synced axis and interaction controls) as documented in the PR to upstream: https://github.com/pyqtgraph/pyqtgraph/pull/2162 More specifically, - wrt to `AxisItem` we added lru caching of tick values as per: https://github.com/pyqtgraph/pyqtgraph/pull/2160. - wrt to `PlotItem` we adjusted some of the axis management code, namely adding a standalone `.removeAxis()` and modifying the `.setAxisItems()` logic to use it in: https://github.com/pyqtgraph/pyqtgraph/pull/2162 as well as some tweaks to `.updateGrid()` to loop through all possible axes when grid setting. --- piker/ui/_pg_overrides.py | 276 +++++++++++++++++++++++++++++++++++++- 1 file changed, 273 insertions(+), 3 deletions(-) diff --git a/piker/ui/_pg_overrides.py b/piker/ui/_pg_overrides.py index 71e5f40a..d7d7d3bc 100644 --- a/piker/ui/_pg_overrides.py +++ b/piker/ui/_pg_overrides.py @@ -15,11 +15,16 @@ # along with this program. If not, see . """ -Customization of ``pyqtgraph`` core routines to speed up our use mostly -based on not requiring "scentific precision" for pixel perfect view -transforms. +Customization of ``pyqtgraph`` core routines and various types normally +for speedups. + +Generally, our does not require "scentific precision" for pixel perfect +view transforms. """ +import functools +from typing import Optional + import pyqtgraph as pg @@ -46,3 +51,268 @@ def _do_overrides() -> None: """ # we don't care about potential fp issues inside Qt pg.functions.invertQTransform = invertQTransform + pg.PlotItem = PlotItem + pg.AxisItem = AxisItem + + +# NOTE: the below customized type contains all our changes on a method +# by method basis as per the diff: +# https://github.com/pyqtgraph/pyqtgraph/commit/8e60bc14234b6bec1369ff4192dbfb82f8682920#diff-a2b5865955d2ba703dbc4c35ff01aa761aa28d2aeaac5e68d24e338bc82fb5b1R500 + +class PlotItem(pg.PlotItem): + ''' + Overrides for the core plot object mostly pertaining to overlayed + multi-view management as it relates to multi-axis managment. + + ''' + def __init__( + self, + parent=None, + name=None, + labels=None, + title=None, + viewBox=None, + axisItems=None, + default_axes=['left', 'bottom'], + enableMenu=True, + **kargs + ): + super().__init__( + parent=parent, + name=name, + labels=labels, + title=title, + viewBox=viewBox, + axisItems=axisItems, + # default_axes=default_axes, + enableMenu=enableMenu, + kargs=kargs, + ) + # self.setAxisItems( + # axisItems, + # default_axes=default_axes, + # ) + + # NOTE: this is an entirely new method not in upstream. + def removeAxis( + self, + name: str, + unlink: bool = True, + + ) -> Optional[pg.AxisItem]: + """ + Remove an axis from the contained axis items + by ```name: str```. + + This means the axis graphics object will be removed + from the ``.layout: QGraphicsGridLayout`` as well as unlinked + from the underlying associated ``ViewBox``. + + If the ``unlink: bool`` is set to ``False`` then the axis will + stay linked to its view and will only be removed from the + layoutonly be removed from the layout. + + If no axis with ``name: str`` is found then this is a noop. + + Return the axis instance that was removed. + + """ + entry = self.axes.pop(name, None) + + if not entry: + return + + axis = entry['item'] + self.layout.removeItem(axis) + axis.scene().removeItem(axis) + if unlink: + axis.unlinkFromView() + + self.update() + + return axis + + # Why do we need to always have all axes created? + # + # I don't understand this at all. + # + # Everything seems to work if you just always apply the + # set passed to this method **EXCEPT** for some super weird reason + # the view box geometry still computes as though the space for the + # `'bottom'` axis is always there **UNLESS** you always add that + # axis but hide it? + # + # Why in tf would this be the case!?!? + def setAxisItems( + self, + # XXX: yeah yeah, i know we can't use type annots like this yet. + axisItems: Optional[dict[str, pg.AxisItem]] = None, + add_to_layout: bool = True, + default_axes: list[str] = ['left', 'bottom'], + ): + """ + Override axis item setting to only + + """ + axisItems = axisItems or {} + + # XXX: wth is is this even saying?!? + # Array containing visible axis items + # Also containing potentially hidden axes, but they are not + # touched so it does not matter + # visibleAxes = ['left', 'bottom'] + # Note that it does not matter that this adds + # some values to visibleAxes a second time + + # XXX: uhhh wat^ ..? + + visibleAxes = list(default_axes) + list(axisItems.keys()) + + # TODO: we should probably invert the loop here to not loop the + # predefined "axis name set" and instead loop the `axisItems` + # input and lookup indices from a predefined map. + for name, pos in ( + ('top', (1, 1)), + ('bottom', (3, 1)), + ('left', (2, 0)), + ('right', (2, 2)) + ): + if ( + name in self.axes and + name in axisItems + ): + # we already have an axis entry for this name + # so remove the existing entry. + self.removeAxis(name) + + # elif name not in axisItems: + # # this axis entry is not provided in this call + # # so remove any old/existing entry. + # self.removeAxis(name) + + # Create new axis + if name in axisItems: + axis = axisItems[name] + if axis.scene() is not None: + if ( + name not in self.axes + or axis != self.axes[name]["item"] + ): + raise RuntimeError( + "Can't add an axis to multiple plots. Shared axes" + " can be achieved with multiple AxisItem instances" + " and set[X/Y]Link.") + + else: + # Set up new axis + + # XXX: ok but why do we want to add axes for all entries + # if not desired by the user? The only reason I can see + # adding this is without it there's some weird + # ``ViewBox`` geometry bug.. where a gap for the + # 'bottom' axis is somehow left in? + axis = AxisItem(orientation=name, parent=self) + + axis.linkToView(self.vb) + + # XXX: shouldn't you already know the ``pos`` from the name? + # Oh right instead of a global map that would let you + # reasily look that up it's redefined over and over and over + # again in methods.. + self.axes[name] = {'item': axis, 'pos': pos} + + # NOTE: in the overlay case the axis may be added to some + # other layout and should not be added here. + if add_to_layout: + self.layout.addItem(axis, *pos) + + # place axis above images at z=0, items that want to draw + # over the axes should be placed at z>=1: + axis.setZValue(0.5) + axis.setFlag( + axis.GraphicsItemFlag.ItemNegativeZStacksBehindParent + ) + if name in visibleAxes: + self.showAxis(name, True) + else: + # why do we need to insert all axes to ``.axes`` and + # only hide the ones the user doesn't specify? It all + # seems to work fine without doing this except for this + # weird gap for the 'bottom' axis that always shows up + # in the view box geometry?? + self.hideAxis(name) + + def updateGrid( + self, + *args, + ): + alpha = self.ctrl.gridAlphaSlider.value() + x = alpha if self.ctrl.xGridCheck.isChecked() else False + y = alpha if self.ctrl.yGridCheck.isChecked() else False + for name, dim in ( + ('top', x), + ('bottom', x), + ('left', y), + ('right', y) + ): + if name in self.axes: + self.getAxis(name).setGrid(dim) + # self.getAxis('bottom').setGrid(x) + # self.getAxis('left').setGrid(y) + # self.getAxis('right').setGrid(y) + + +# NOTE: overrides to lru_cache the ``.tickStrings()`` output. +class AxisItem(pg.AxisItem): + + def __init__( + self, + orientation, + pen=None, + textPen=None, + linkView=None, + parent=None, + maxTickLength=-5, + showValues=True, + text='', + units='', + unitPrefix='', + lru_cache_tick_strings: bool = True, + **args, + ): + super().__init__( + orientation=orientation, + pen=pen, + textPen=textPen, + linkView=linkView, + maxTickLength=maxTickLength, + showValues=showValues, + text=text, + units=units, + unitPrefix=unitPrefix, + parent=parent, + ) + if lru_cache_tick_strings: + self.tickStrings = functools.lru_cache( + maxsize=2**20 + )(self.tickStrings) + + def tickValues( + self, + minVal: float, + maxVal: float, + size: int, + + ) -> list[tuple[float, tuple[str]]]: + ''' + Repack tick values into tuples for lru caching. + + ''' + ticks = [] + for scalar, values in super().tickValues(minVal, maxVal, size): + ticks.append(( + scalar, + tuple(values), # this + )) + + return ticks