diff --git a/piker/ui/_overlay.py b/piker/ui/_overlay.py new file mode 100644 index 00000000..fd22e8e5 --- /dev/null +++ b/piker/ui/_overlay.py @@ -0,0 +1,272 @@ +# piker: trading gear for hackers +# Copyright (C) Tyler Goodlet (in stewardship for pikers) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +''' +Charting overlay helpers. + +''' +from pyqtgraph.Qt.QtCore import ( + # QObject, + # Signal, + Qt, + # QEvent, +) +from pyqtgraph.graphicsItems.AxisItem import AxisItem +# from pyqtgraph.graphicsItems.ViewBox import ViewBox +from pyqtgraph.graphicsItems.PlotItem.PlotItem import PlotItem +from pyqtgraph.Qt.QtWidgets import QGraphicsGridLayout, QGraphicsLinearLayout + +# Define the layout "position" indices as to be passed +# to a ``QtWidgets.QGraphicsGridlayout.addItem()`` call: +# https://doc.qt.io/qt-5/qgraphicsgridlayout.html#addItem +# This was pulled from the internals of ``PlotItem.setAxisItem()``. +_axes_layout_indices: dict[str] = { + # row incremented axes + 'top': (1, 1), + 'bottom': (3, 1), + + # view is @ (2, 1) + + # column incremented axes + 'left': (2, 0), + 'right': (2, 2), +} +# NOTE: To clarify this indexing, ``PlotItem.__init__()`` makes a grid +# with dimensions 4x3 and puts the ``ViewBox`` at postiion (2, 1) (aka +# row=2, col=1) in the grid layout since row (0, 1) is reserved for +# a title label and row 1 is for any potential "top" axis. Column 1 +# is the "middle" (since 3 columns) and is where the plot/vb is placed. + + +class ComposedGridLayout: + ''' + List-like interface to managing a sequence of overlayed + ``PlotItem``s in the form: + + | | | | | top0 | | | | | + | | | | | top1 | | | | | + | | | | | ... | | | | | + | | | | | topN | | | | | + | lN | ... | l1 | l0 | ViewBox | r0 | r1 | ... | rN | + | | | | | bottom0 | | | | | + | | | | | bottom1 | | | | | + | | | | | ... | | | | | + | | | | | bottomN | | | | | + + Where the index ``i`` in the sequence specifies the index + ``i`` in the layout. + + The ``item: PlotItem`` passed to the constructor's grid layout is + used verbatim as the "main plot" who's view box is give precedence + for input handling. The main plot's axes are removed from it's + layout and placed in the surrounding exterior layouts to allow for + re-ordering if desired. + + ''' + def __init__( + self, + item: PlotItem, + grid: QGraphicsGridLayout, + reverse: bool = False, # insert items to the "center" + + ) -> None: + self.items: list[PlotItem] = [] + # self.grid = grid + self.reverse = reverse + + # TODO: use a ``bidict`` here? + self._pi2axes: dict[ + int, + dict[str, AxisItem], + ] = {} + + self._axes2pi: dict[ + AxisItem, + dict[str, PlotItem], + ] = {} + + # TODO: better name? + # construct surrounding layouts for placing outer axes and + # their legends and title labels. + self.sides: dict[ + str, + tuple[QGraphicsLinearLayout, list[AxisItem]] + ] = {} + + for name, pos in _axes_layout_indices.items(): + layout = QGraphicsLinearLayout() + self.sides[name] = (layout, []) + + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + if name in ('top', 'bottom'): + orient = Qt.Vertical + elif name in ('left', 'right'): + orient = Qt.Horizontal + + layout.setOrientation(orient) + + self.insert(0, item) + + # insert surrounding linear layouts into the parent pi's layout + # such that additional axes can be appended arbitrarily without + # having to expand or resize the parent's grid layout. + for name, (linlayout, axes) in self.sides.items(): + + # TODO: do we need this? + # axis should have been removed during insert above + index = _axes_layout_indices[name] + axis = item.layout.itemAt(*index) + if axis and axis.isVisible(): + assert linlayout.itemAt(0) is axis + + # item.layout.removeItem(axis) + item.layout.addItem(linlayout, *index) + layout = item.layout.itemAt(*index) + assert layout is linlayout + + def _register_item( + self, + index: int, + plotitem: PlotItem, + + ) -> None: + for name, axis_info in plotitem.axes.items(): + axis = axis_info['item'] + # register this plot's (maybe re-placed) axes for lookup. + self._pi2axes.setdefault(index, {})[name] = axis + self._axes2pi.setdefault(index, {})[name] = plotitem + + # enter plot into list for index tracking + self.items.insert(index, plotitem) + + def insert( + self, + index: int, + plotitem: PlotItem, + + ) -> (int, int): + ''' + Place item at index by inserting all axes into the grid + at list-order appropriate position. + + ''' + if index < 0: + raise ValueError(f'`insert()` only supports an index >= 0') + + # add plot's axes in sequence to the embedded linear layouts + # for each "side" thus avoiding graphics collisions. + for name, axis_info in plotitem.axes.copy().items(): + linlayout, axes = self.sides[name] + axis = axis_info['item'] + + if axis in axes: + ValueError(f'{axis} is already in {name} layout!?') + + # linking sanity + axis_view = axis.linkedView() + assert axis_view is plotitem.vb + + if ( + not axis.isVisible() + + # XXX: we never skip moving the axes for the *first* + # plotitem inserted (even if not shown) since we need to + # move all the hidden axes into linear sub-layouts for + # that "central" plot in the overlay. Also if we don't + # do it there's weird geomoetry calc offsets that make + # view coords slightly off somehow .. smh + and not len(self.items) == 0 + ): + continue + + # XXX: Remove old axis? No, turns out we don't need this? + # DON'T unlink it since we the original ``ViewBox`` + # to still drive it B) + # popped = plotitem.removeAxis(name, unlink=False) + # assert axis is popped + + # invert insert index for layouts which are + # not-left-to-right, top-to-bottom insert oriented + if name in ('top', 'left'): + index = min(len(axes) - index, 0) + assert index >= 0 + + # elif name in ('bottom', 'right'): + # i_dim = 1 + + # TODO: increment logic for layout on 'top'/'left' axes + # sets.. looks like ther'es no way around an unwind and + # re-stack of the layout to include all labels, unless + # we use a different layout system (cough). + + # if name in ('top', 'left'): + # increment = -1 + # elif name in ('right', 'bottom'): + # increment = +1 + + # increment = +count + + # index = list(_axes_layout_indices[name]) + # current = index[i_dim] + # index[i_dim] = current + increment if current > 0 else 0 + + linlayout.insertItem(index, axis) + # axis.setLayout(linlayout) + axes.insert(index, axis) + + self._register_item(index, plotitem) + + return index + + def append( + self, + item: PlotItem, + + ) -> (int, int): + ''' + Append item's axes at indexes which put its axes "outside" + previously overlayed entries. + + ''' + # for left and bottom axes we have to first remove + # items and re-insert to maintain a list-order. + return self.insert(len(self.items), item) + + def get_axis( + self, + plot: PlotItem, + name: str, + + ) -> AxisItem: + ''' + Retrieve the named axis for overlayed ``plot``. + + ''' + index = self.items.index(plot) + return self._pi2axes[index][name] + + def pop( + self, + item: PlotItem, + + ) -> PlotItem: + ''' + Remove item and restack all axes in list-order. + + ''' + ...