diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index 53707407..67df0138 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -18,6 +18,7 @@ Chart axes graphics and behavior. """ +import functools from typing import List, Tuple, Optional from math import floor @@ -33,17 +34,18 @@ _axis_pen = pg.mkPen(hcolor('bracket')) class Axis(pg.AxisItem): - """A better axis that sizes tick contents considering font size. + ''' + A better axis that sizes tick contents considering font size. - """ + ''' def __init__( self, linkedsplits, typical_max_str: str = '100 000.000', min_tick: int = 2, **kwargs - ) -> None: + ) -> None: super().__init__(**kwargs) # XXX: pretty sure this makes things slower @@ -95,7 +97,12 @@ class PriceAxis(Axis): # XXX: drop for now since it just eats up h space - def tickStrings(self, vals, scale, spacing): + def tickStrings( + self, + vals, + scale, + spacing, + ): # TODO: figure out how to enforce min tick spacing by passing # it into the parent type @@ -131,9 +138,8 @@ class DynamicDateAxis(Axis): indexes: List[int], ) -> List[str]: - # try: chart = self.linkedsplits.chart - bars = chart._arrays['ohlc'] + bars = chart._arrays[chart.name] shm = self.linkedsplits.chart._shm first = shm._first.value @@ -156,7 +162,14 @@ class DynamicDateAxis(Axis): delay = times[-1] - times[-2] return dts.strftime(self.tick_tpl[delay]) - def tickStrings(self, values: List[float], scale, spacing): + def tickStrings( + self, + values: tuple[float], + scale, + spacing, + ): + # info = self.tickStrings.cache_info() + # print(info) return self._indexes_to_timestrs(values) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 6d4ebc83..3ac2cf14 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -19,6 +19,8 @@ High level chart-widget apis. ''' from __future__ import annotations +from functools import partial +from dataclasses import dataclass from typing import Optional from PyQt5 import QtCore, QtWidgets @@ -29,7 +31,6 @@ from PyQt5.QtWidgets import ( QHBoxLayout, QVBoxLayout, QSplitter, - # QSizePolicy, ) import numpy as np import pyqtgraph as pg @@ -61,6 +62,7 @@ from ..data._sharedmem import ShmArray from ..log import get_logger from ._interaction import ChartView from ._forms import FieldsForm +from ._overlay import PlotItemOverlay log = get_logger(__name__) @@ -322,17 +324,8 @@ class LinkedSplits(QWidget): self.subplots: dict[tuple[str, ...], ChartPlotWidget] = {} self.godwidget = godwidget - - self.xaxis = DynamicDateAxis( - orientation='bottom', - linkedsplits=self - ) - # if _xaxis_at == 'bottom': - # self.xaxis.setStyle(showValues=False) - # self.xaxis.hide() - # else: - # self.xaxis_ind.setStyle(showValues=False) - # self.xaxis.hide() + # placeholder for last appended ``PlotItem``'s bottom axis. + self.xaxis_chart = None self.splitter = QSplitter(QtCore.Qt.Vertical) self.splitter.setMidLineWidth(0) @@ -410,7 +403,6 @@ class LinkedSplits(QWidget): name=symbol.key, array=array, - # xaxis=self.xaxis, style=style, _is_main=True, @@ -420,7 +412,10 @@ class LinkedSplits(QWidget): self.chart.addItem(self.cursor) # axis placement - if _xaxis_at == 'bottom': + if ( + _xaxis_at == 'bottom' and + 'bottom' in self.chart.plotItem.axes + ): self.chart.hideAxis('bottom') # style? @@ -438,7 +433,6 @@ class LinkedSplits(QWidget): array: np.ndarray, array_key: Optional[str] = None, - # xaxis: Optional[DynamicDateAxis] = None, style: str = 'line', _is_main: bool = False, @@ -446,31 +440,28 @@ class LinkedSplits(QWidget): **cpw_kwargs, - ) -> 'ChartPlotWidget': - '''Add (sub)plots to chart widget by key. + ) -> ChartPlotWidget: + ''' + Add (sub)plots to chart widget by key. ''' if self.chart is None and not _is_main: raise RuntimeError( "A main plot must be created first with `.plot_ohlc_main()`") - # source of our custom interactions - cv = ChartView(name) - cv.linkedsplits = self - # use "indicator axis" by default # TODO: we gotta possibly assign this back # to the last subplot on removal of some last subplot - xaxis = DynamicDateAxis( orientation='bottom', linkedsplits=self ) - - if self.xaxis: - self.xaxis.hide() - self.xaxis = xaxis + axes = { + 'right': PriceAxis(linkedsplits=self, orientation='right'), + 'left': PriceAxis(linkedsplits=self, orientation='left'), + 'bottom': xaxis, + } qframe = ChartnPane( sidepane=sidepane, @@ -486,15 +477,21 @@ class LinkedSplits(QWidget): array=array, parent=qframe, linkedsplits=self, - axisItems={ - 'bottom': xaxis, - 'right': PriceAxis(linkedsplits=self, orientation='right'), - 'left': PriceAxis(linkedsplits=self, orientation='left'), - }, - viewBox=cv, + axisItems=axes, **cpw_kwargs, ) + if self.xaxis_chart: + # presuming we only want it at the true bottom of all charts. + # XXX: uses new api from our ``pyqtgraph`` fork. + # https://github.com/pikers/pyqtgraph/tree/plotitemoverlay_onto_pg_master + _ = self.xaxis_chart.removeAxis('bottom', unlink=False) + assert 'bottom' not in self.xaxis_chart.plotItem.axes + self.xaxis_chart = cpw + + if self.xaxis_chart is None: + self.xaxis_chart = cpw + qframe.chart = cpw qframe.hbox.addWidget(cpw) @@ -510,17 +507,13 @@ class LinkedSplits(QWidget): ) cpw.sidepane = sidepane - # give viewbox as reference to chart - # allowing for kb controls and interactions on **this** widget - # (see our custom view mode in `._interactions.py`) - cv.chart = cpw - cpw.plotItem.vb.linkedsplits = self cpw.setFrameStyle( QtWidgets.QFrame.StyledPanel # | QtWidgets.QFrame.Plain ) + # don't show the little "autoscale" A label. cpw.hideButtons() # XXX: gives us outline on backside of y-axis @@ -531,15 +524,27 @@ class LinkedSplits(QWidget): # comes from ;) cpw.setXLink(self.chart) - # add to cross-hair's known plots - self.cursor.add_plot(cpw) + add_label = False + anchor_at = ('top', 'left') # draw curve graphics if style == 'bar': - cpw.draw_ohlc(name, array, array_key=array_key) + + graphics, data_key = cpw.draw_ohlc( + name, + array, + array_key=array_key + ) + self.cursor.contents_labels.add_label( + cpw, + 'ohlc', + anchor_at=('top', 'left'), + update_func=ContentsLabel.update_from_ohlc, + ) elif style == 'line': - cpw.draw_curve( + add_label = True + graphics, data_key = cpw.draw_curve( name, array, array_key=array_key, @@ -547,7 +552,8 @@ class LinkedSplits(QWidget): ) elif style == 'step': - cpw.draw_curve( + add_label = True + graphics, data_key = cpw.draw_curve( name, array, array_key=array_key, @@ -569,6 +575,22 @@ class LinkedSplits(QWidget): else: assert style == 'bar', 'main chart must be OHLC' + # add to cross-hair's known plots + # NOTE: add **AFTER** creating the underlying ``PlotItem``s + # since we require that global (linked charts wide) axes have + # been created! + self.cursor.add_plot(cpw) + + if self.cursor and style != 'bar': + self.cursor.add_curve_cursor(cpw, graphics) + + if add_label: + self.cursor.contents_labels.add_label( + cpw, + data_key, + anchor_at=anchor_at, + ) + self.resize_sidepanes() return cpw @@ -587,6 +609,18 @@ class LinkedSplits(QWidget): cpw.sidepane.setMinimumWidth(sp_w) cpw.sidepane.setMaximumWidth(sp_w) +# import pydantic + +# class ArrayScene(pydantic.BaseModel): +# ''' +# Data-AGGRegate: high level API onto multiple (categorized) +# ``ShmArray``s with high level processing routines mostly for +# graphics summary and display. + +# ''' +# arrays: dict[str, np.ndarray] = {} +# graphics: dict[str, pg.GraphicsObject] = {} + class ChartPlotWidget(pg.PlotWidget): ''' @@ -611,6 +645,10 @@ class ChartPlotWidget(pg.PlotWidget): # TODO: can take a ``background`` color setting - maybe there's # a better one? + def mk_vb(self, name: str) -> ChartView: + cv = ChartView(name) + cv.linkedsplits = self.linked + return cv def __init__( self, @@ -639,17 +677,31 @@ class ChartPlotWidget(pg.PlotWidget): self.view_color = view_color self.pen_color = pen_color + # NOTE: must be set bfore calling ``.mk_vb()`` + self.linked = linkedsplits + + # source of our custom interactions + self.cv = cv = self.mk_vb(name) + super().__init__( background=hcolor(view_color), + viewBox=cv, # parent=None, # plotItem=None, # antialias=True, **kwargs ) + # give viewbox as reference to chart + # allowing for kb controls and interactions on **this** widget + # (see our custom view mode in `._interactions.py`) + cv.chart = self + + # ensure internal pi matches + assert self.cv is self.plotItem.vb + self.useOpenGL(use_open_gl) self.name = name - self.data_key = data_key - self.linked = linkedsplits + self.data_key = data_key or name # scene-local placeholder for book graphics # sizing to avoid overlap with data contents @@ -658,9 +710,10 @@ class ChartPlotWidget(pg.PlotWidget): # self.setViewportMargins(0, 0, 0, 0) # self._ohlc = array # readonly view of ohlc data + # TODO: move to Aggr above XD # readonly view of data arrays self._arrays = { - 'ohlc': array, + self.data_key: array, } self._graphics = {} # registry of underlying graphics # registry of overlay curve names @@ -671,7 +724,6 @@ class ChartPlotWidget(pg.PlotWidget): self._labels = {} # registry of underlying graphics self._ysticks = {} # registry of underlying graphics - self._vb = self.plotItem.vb self._static_yrange = static_yrange # for "known y-range style" self._view_mode: str = 'follow' @@ -684,16 +736,9 @@ class ChartPlotWidget(pg.PlotWidget): self.showGrid(x=False, y=True, alpha=0.3) self.default_view() + self.cv.enable_auto_yrange() - # Assign callback for rescaling y-axis automatically - # based on data contents and ``ViewBox`` state. - # self.sigXRangeChanged.connect(self._set_yrange) - - # for mouse wheel which doesn't seem to emit XRangeChanged - self._vb.sigRangeChangedManually.connect(self._set_yrange) - - # for when the splitter(s) are resized - self._vb.sigResized.connect(self._set_yrange) + self.overlay: PlotItemOverlay = PlotItemOverlay(self.plotItem) def resume_all_feeds(self): for feed in self._feeds.values(): @@ -705,16 +750,16 @@ class ChartPlotWidget(pg.PlotWidget): @property def view(self) -> ChartView: - return self._vb + return self.plotItem.vb def focus(self) -> None: - self._vb.setFocus() + self.view.setFocus() def last_bar_in_view(self) -> int: - self._arrays['ohlc'][-1]['index'] + self._arrays[self.name][-1]['index'] def is_valid_index(self, index: int) -> bool: - return index >= 0 and index < self._arrays['ohlc'][-1]['index'] + return index >= 0 and index < self._arrays[self.name][-1]['index'] def _set_xlimits( self, @@ -738,7 +783,7 @@ class ChartPlotWidget(pg.PlotWidget): """Return a range tuple for the bars present in view. """ l, r = self.view_range() - array = self._arrays['ohlc'] + array = self._arrays[self.name] lbar = max(l, array[0]['index']) rbar = min(r, array[-1]['index']) return l, lbar, rbar, r @@ -750,7 +795,7 @@ class ChartPlotWidget(pg.PlotWidget): """Set the view box to the "default" startup view of the scene. """ - xlast = self._arrays['ohlc'][index]['index'] + xlast = self._arrays[self.name][index]['index'] begin = xlast - _bars_to_left_in_follow_mode end = xlast + _bars_from_right_in_follow_mode @@ -758,12 +803,13 @@ class ChartPlotWidget(pg.PlotWidget): if self._static_yrange == 'axis': self._static_yrange = None - self.plotItem.vb.setXRange( + view = self.view + view.setXRange( min=begin, max=end, padding=0, ) - self._set_yrange() + view._set_yrange() def increment_view( self, @@ -774,7 +820,7 @@ class ChartPlotWidget(pg.PlotWidget): """ l, r = self.view_range() - self._vb.setXRange( + self.view.setXRange( min=l + 1, max=r + 1, @@ -791,11 +837,11 @@ class ChartPlotWidget(pg.PlotWidget): array_key: Optional[str] = None, - ) -> pg.GraphicsObject: - """ + ) -> (pg.GraphicsObject, str): + ''' Draw OHLC datums to chart. - """ + ''' graphics = BarItems( self.plotItem, pen_color=self.pen_color @@ -810,17 +856,9 @@ class ChartPlotWidget(pg.PlotWidget): data_key = array_key or name self._graphics[data_key] = graphics - - self.linked.cursor.contents_labels.add_label( - self, - 'ohlc', - anchor_at=('top', 'left'), - update_func=ContentsLabel.update_from_ohlc, - ) - self._add_sticky(name, bg_color='davies') - return graphics + return graphics, data_key def draw_curve( self, @@ -830,16 +868,18 @@ class ChartPlotWidget(pg.PlotWidget): array_key: Optional[str] = None, overlay: bool = False, + separate_axes: bool = False, color: Optional[str] = None, add_label: bool = True, **pdi_kwargs, - ) -> pg.PlotDataItem: - """Draw a "curve" (line plot graphics) for the provided data in + ) -> (pg.PlotDataItem, str): + ''' + Draw a "curve" (line plot graphics) for the provided data in the input array ``data``. - """ + ''' color = color or self.pen_color or 'default_light' pdi_kwargs.update({ 'color': color @@ -847,10 +887,6 @@ class ChartPlotWidget(pg.PlotWidget): data_key = array_key or name - # pg internals for reference. - # curve = pg.PlotDataItem( - # curve = pg.PlotCurveItem( - # yah, we wrote our own B) curve = FastAppendCurve( y=data[data_key], @@ -881,34 +917,88 @@ class ChartPlotWidget(pg.PlotWidget): # and is disastrous for performance. # curve.setCacheMode(QtWidgets.QGraphicsItem.ItemCoordinateCache) - self.addItem(curve) - # register curve graphics and backing array for name self._graphics[name] = curve - self._arrays[data_key or name] = data + self._arrays[data_key] = data if overlay: - anchor_at = ('bottom', 'left') + # anchor_at = ('bottom', 'left') self._overlays[name] = None + if separate_axes: + + # Custom viewbox impl + cv = self.mk_vb(name) + + def maxmin(): + return self.maxmin(name=data_key) + + # ensure view maxmin is computed from correct array + # cv._maxmin = partial(self.maxmin, name=data_key) + + cv._maxmin = maxmin + + cv.chart = self + + # xaxis = DynamicDateAxis( + # orientation='bottom', + # linkedsplits=self.linked, + # ) + yaxis = PriceAxis( + orientation='right', + linkedsplits=self.linked, + ) + + plotitem = pg.PlotItem( + parent=self.plotItem, + name=name, + enableMenu=False, + viewBox=cv, + axisItems={ + # 'bottom': xaxis, + 'right': yaxis, + }, + default_axes=[], + ) + # plotitem.setAxisItems( + # add_to_layout=False, + # axisItems={ + # 'bottom': xaxis, + # 'right': yaxis, + # }, + # ) + # plotite.hideAxis('right') + # plotite.hideAxis('bottom') + plotitem.addItem(curve) + cv.enable_auto_yrange() + + # config + # plotitem.setAutoVisible(y=True) + # plotitem.enableAutoRange(axis='y') + plotitem.hideButtons() + + self.overlay.add_plotitem( + plotitem, + # only link x-axes, + link_axes=(0,), + ) + + else: + # this intnernally calls `PlotItem.addItem()` on the + # graphics object + self.addItem(curve) else: - anchor_at = ('top', 'left') + # this intnernally calls `PlotItem.addItem()` on the + # graphics object + self.addItem(curve) + + # anchor_at = ('top', 'left') # TODO: something instead of stickies for overlays # (we need something that avoids clutter on x-axis). self._add_sticky(name, bg_color=color) - if self.linked.cursor: - self.linked.cursor.add_curve_cursor(self, curve) - - if add_label: - self.linked.cursor.contents_labels.add_label( - self, - data_key or name, - anchor_at=anchor_at - ) - - return curve + return curve, data_key # TODO: make this a ctx mngr def _add_sticky( @@ -949,7 +1039,7 @@ class ChartPlotWidget(pg.PlotWidget): '''Update the named internal graphics from ``array``. ''' - self._arrays['ohlc'] = array + self._arrays[self.name] = array graphics = self._graphics[graphics_name] graphics.update_from_array(array, **kwargs) return graphics @@ -970,7 +1060,7 @@ class ChartPlotWidget(pg.PlotWidget): data_key = array_key or graphics_name if graphics_name not in self._overlays: - self._arrays['ohlc'] = array + self._arrays[self.name] = array else: self._arrays[data_key] = array @@ -992,102 +1082,6 @@ class ChartPlotWidget(pg.PlotWidget): return curve - def _set_yrange( - self, - *, - - yrange: Optional[tuple[float, float]] = None, - range_margin: float = 0.06, - bars_range: Optional[tuple[int, int, int, int]] = None, - - # flag to prevent triggering sibling charts from the same linked - # set from recursion errors. - autoscale_linked_plots: bool = True, - - ) -> None: - '''Set the viewable y-range based on embedded data. - - This adds auto-scaling like zoom on the scroll wheel such - that data always fits nicely inside the current view of the - data set. - - ''' - set_range = True - - if self._static_yrange == 'axis': - set_range = False - - elif self._static_yrange is not None: - ylow, yhigh = self._static_yrange - - elif yrange is not None: - ylow, yhigh = yrange - - else: - # Determine max, min y values in viewable x-range from data. - # Make sure min bars/datums on screen is adhered. - - l, lbar, rbar, r = bars_range or self.bars_range() - - if autoscale_linked_plots: - # avoid recursion by sibling plots - linked = self.linked - plots = list(linked.subplots.copy().values()) - main = linked.chart - if main: - plots.append(main) - - for chart in plots: - if chart and not chart._static_yrange: - chart._set_yrange( - bars_range=(l, lbar, rbar, r), - autoscale_linked_plots=False, - ) - - # TODO: logic to check if end of bars in view - # extra = view_len - _min_points_to_show - # begin = self._arrays['ohlc'][0]['index'] - extra - # # end = len(self._arrays['ohlc']) - 1 + extra - # end = self._arrays['ohlc'][-1]['index'] - 1 + extra - - # bars_len = rbar - lbar - # log.debug( - # f"\nl: {l}, lbar: {lbar}, rbar: {rbar}, r: {r}\n" - # f"view_len: {view_len}, bars_len: {bars_len}\n" - # f"begin: {begin}, end: {end}, extra: {extra}" - # ) - - a = self._arrays['ohlc'] - ifirst = a[0]['index'] - bars = a[lbar - ifirst:rbar - ifirst + 1] - - if not len(bars): - # likely no data loaded yet or extreme scrolling? - log.error(f"WTF bars_range = {lbar}:{rbar}") - return - - if self.data_key != self.linked.symbol.key: - bars = bars[self.data_key] - ylow = np.nanmin(bars) - yhigh = np.nanmax(bars) - # print(f'{(ylow, yhigh)}') - else: - # just the std ohlc bars - ylow = np.nanmin(bars['low']) - yhigh = np.nanmax(bars['high']) - - if set_range: - # view margins: stay within a % of the "true range" - diff = yhigh - ylow - ylow = ylow - (diff * range_margin) - yhigh = yhigh + (diff * range_margin) - - self.setLimits( - yMin=ylow, - yMax=yhigh, - ) - self.setYRange(ylow, yhigh) - # def _label_h(self, yhigh: float, ylow: float) -> float: # # compute contents label "height" in view terms # # to avoid having data "contents" overlap with them @@ -1140,3 +1134,59 @@ class ChartPlotWidget(pg.PlotWidget): return ohlc['index'][indexes][-1] else: return ohlc['index'][-1] + + def maxmin( + self, + name: Optional[str] = None, + bars_range: Optional[tuple[int, int, int, int]] = None, + + ) -> tuple[float, float]: + ''' + Return the max and min y-data values "in view". + + If ``bars_range`` is provided use that range. + + ''' + l, lbar, rbar, r = bars_range or self.bars_range() + # TODO: logic to check if end of bars in view + # extra = view_len - _min_points_to_show + # begin = self._arrays['ohlc'][0]['index'] - extra + # # end = len(self._arrays['ohlc']) - 1 + extra + # end = self._arrays['ohlc'][-1]['index'] - 1 + extra + + # bars_len = rbar - lbar + # log.debug( + # f"\nl: {l}, lbar: {lbar}, rbar: {rbar}, r: {r}\n" + # f"view_len: {view_len}, bars_len: {bars_len}\n" + # f"begin: {begin}, end: {end}, extra: {extra}" + # ) + + a = self._arrays[name or self.name] + ifirst = a[0]['index'] + bars = a[lbar - ifirst:rbar - ifirst + 1] + + if not len(bars): + # likely no data loaded yet or extreme scrolling? + log.error(f"WTF bars_range = {lbar}:{rbar}") + return + + if ( + self.data_key == self.linked.symbol.key + ): + # ohlc sampled bars hi/lo lookup + ylow = np.nanmin(bars['low']) + yhigh = np.nanmax(bars['high']) + + else: + try: + view = bars[name or self.data_key] + except: + breakpoint() + # if self.data_key != 'volume': + # else: + # view = bars + ylow = np.nanmin(view) + yhigh = np.nanmax(view) + # print(f'{(ylow, yhigh)}') + + return ylow, yhigh diff --git a/piker/ui/_cursor.py b/piker/ui/_cursor.py index fd9df0f0..d9a4e45a 100644 --- a/piker/ui/_cursor.py +++ b/piker/ui/_cursor.py @@ -276,7 +276,7 @@ class ContentsLabels: ) -> ContentsLabel: label = ContentsLabel( - view=chart._vb, + view=chart.view, anchor_at=anchor_at, ) self._labels.append( @@ -418,13 +418,16 @@ class Cursor(pg.GraphicsObject): # keep x-axis right below main chart plot_index = -1 if _xaxis_at == 'bottom' else 0 - self.xaxis_label = XAxisLabel( - parent=self.plots[plot_index].getAxis('bottom'), - opacity=_ch_label_opac, - bg_color=self.label_color, - ) - # place label off-screen during startup - self.xaxis_label.setPos(self.plots[0].mapFromView(QPointF(0, 0))) + # ONLY create an x-axis label for the cursor + # if this plot owns the 'bottom' axis. + if 'bottom' in plot.plotItem.axes: + self.xaxis_label = XAxisLabel( + parent=self.plots[plot_index].getAxis('bottom'), + opacity=_ch_label_opac, + bg_color=self.label_color, + ) + # place label off-screen during startup + self.xaxis_label.setPos(self.plots[0].mapFromView(QPointF(0, 0))) def add_curve_cursor( self, @@ -435,7 +438,7 @@ class Cursor(pg.GraphicsObject): # the current sample under the mouse cursor = LineDot( curve, - index=plot._arrays['ohlc'][-1]['index'], + index=plot._arrays[plot.name][-1]['index'], plot=plot ) plot.addItem(cursor) @@ -525,17 +528,18 @@ class Cursor(pg.GraphicsObject): for cursor in opts.get('cursors', ()): cursor.setIndex(ix) - # update the label on the bottom of the crosshair - self.xaxis_label.update_label( + # update the label on the bottom of the crosshair + if 'bottom' in plot.plotItem.axes: + self.xaxis_label.update_label( - # XXX: requires: - # https://github.com/pyqtgraph/pyqtgraph/pull/1418 - # otherwise gobbles tons of CPU.. + # XXX: requires: + # https://github.com/pyqtgraph/pyqtgraph/pull/1418 + # otherwise gobbles tons of CPU.. - # map back to abs (label-local) coordinates - abs_pos=plot.mapFromView(QPointF(ix + line_offset, iy)), - value=ix, - ) + # map back to abs (label-local) coordinates + abs_pos=plot.mapFromView(QPointF(ix + line_offset, iy)), + value=ix, + ) self._datum_xy = ix, iy diff --git a/piker/ui/_display.py b/piker/ui/_display.py index 4c4aed1f..657d203a 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -117,7 +117,7 @@ def update_fsp_chart( array, array_key=array_key or graphics_name, ) - chart._set_yrange() + chart.cv._set_yrange() # XXX: re: ``array_key``: fsp func names must be unique meaning we # can't have duplicates of the underlying data even if multiple @@ -155,7 +155,7 @@ def chart_maxmin( # https://arxiv.org/abs/cs/0610046 # https://github.com/lemire/pythonmaxmin - array = chart._arrays['ohlc'] + array = chart._arrays[chart.name] ifirst = array[0]['index'] last_bars_range = chart.bars_range() @@ -212,6 +212,7 @@ async def update_chart_from_quotes( if vlm_chart: vlm_sticky = vlm_chart._ysticks['volume'] + vlm_view = vlm_chart.view maxmin = partial(chart_maxmin, chart, vlm_chart) @@ -248,6 +249,7 @@ async def update_chart_from_quotes( tick_margin = 3 * tick_size chart.show() + view = chart.view last_quote = time.time() async for quotes in stream: @@ -295,8 +297,10 @@ async def update_chart_from_quotes( mx_vlm_in_view != last_mx_vlm or mx_vlm_in_view > last_mx_vlm ): - # print(f'mx vlm: {last_mx_vlm} -> {mx_vlm_in_view}') - vlm_chart._set_yrange(yrange=(0, mx_vlm_in_view * 1.375)) + print(f'mx vlm: {last_mx_vlm} -> {mx_vlm_in_view}') + vlm_view._set_yrange( + yrange=(0, mx_vlm_in_view * 1.375) + ) last_mx_vlm = mx_vlm_in_view ticks_frame = quote.get('ticks', ()) @@ -412,9 +416,12 @@ async def update_chart_from_quotes( l1.bid_label.update_fields({'level': price, 'size': size}) # check for y-range re-size - if (mx > last_mx) or (mn < last_mn): - # print(f'new y range: {(mn, mx)}') - chart._set_yrange( + if ( + (mx > last_mx) or (mn < last_mn) + and not chart._static_yrange == 'axis' + ): + print(f'new y range: {(mn, mx)}') + view._set_yrange( yrange=(mn, mx), # TODO: we should probably scale # the view margin based on the size @@ -436,6 +443,7 @@ async def update_chart_from_quotes( name, array_key=name, ) + subchart.cv._set_yrange() # TODO: all overlays on all subplots.. @@ -447,6 +455,7 @@ async def update_chart_from_quotes( curve_name, array_key=curve_name, ) + # chart._set_yrange() def maybe_mk_fsp_shm( @@ -790,7 +799,7 @@ async def update_chart_from_fsp( level_line(chart, 70, orient_v='bottom') level_line(chart, 80, orient_v='top') - chart._set_yrange() + chart.cv._set_yrange() done() # status updates profiler(f'fsp:{func_name} starting update loop') @@ -981,7 +990,7 @@ async def maybe_open_vlm_display( ) # size view to data once at outset - chart._set_yrange() + chart.cv._set_yrange() yield chart @@ -1070,7 +1079,7 @@ async def display_symbol_data( ) # size view to data once at outset - chart._set_yrange() + chart.cv._set_yrange() # TODO: a data view api that makes this less shit chart._shm = ohlcv diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 883f7a15..9a99d2f7 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -342,7 +342,8 @@ class SelectRect(QtGui.QGraphicsRectItem): ixmn, ixmx = round(xmn), round(xmx) nbars = ixmx - ixmn + 1 - data = self._chart._arrays['ohlc'][ixmn:ixmx] + chart = self._chart + data = chart._arrays[chart.name][ixmn:ixmx] if len(data): std = data['close'].std() diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 9f33253d..4168a3ff 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -18,6 +18,7 @@ Chart view box primitives """ +from __future__ import annotations from contextlib import asynccontextmanager import time from typing import Optional, Callable @@ -155,6 +156,7 @@ async def handle_viewmode_kb_inputs( # View modes if key == Qt.Key_R: + # TODO: set this for all subplots # edge triggered default view activation view.chart.default_view() @@ -332,12 +334,23 @@ class ChartView(ViewBox): ''' mode_name: str = 'view' + # "relay events" for making overlaid views work. + # NOTE: these MUST be defined here (and can't be monkey patched + # on later) due to signal construction requiring refs to be + # in place during the run of meta-class machinery. + mouseDragEventRelay = QtCore.Signal(object, object, object) + wheelEventRelay = QtCore.Signal(object, object, object) + + event_relay_source: 'Optional[ViewBox]' = None + relays: dict[str, Signal] = {} + def __init__( self, name: str, parent: pg.PlotItem = None, + static_yrange: Optional[tuple[float, float]] = None, **kwargs, ): @@ -350,8 +363,15 @@ class ChartView(ViewBox): **kwargs ) + # for "known y-range style" + self._static_yrange = static_yrange + self._maxmin = None + # disable vertical scrolling - self.setMouseEnabled(x=True, y=False) + self.setMouseEnabled( + x=True, + y=True, + ) self.linkedsplits = None self._chart: 'ChartPlotWidget' = None # noqa @@ -398,8 +418,15 @@ class ChartView(ViewBox): def chart(self, chart: 'ChartPlotWidget') -> None: # type: ignore # noqa self._chart = chart self.select_box.chart = chart + if self._maxmin is None: + self._maxmin = chart.maxmin - def wheelEvent(self, ev, axis=None): + def wheelEvent( + self, + ev, + axis=None, + relayed_from: ChartView = None, + ): '''Override "center-point" location for scrolling. This is an override of the ``ViewBox`` method simply changing @@ -424,7 +451,7 @@ class ChartView(ViewBox): log.debug("Max zoom bruh...") return - if ev.delta() < 0 and vl >= len(chart._arrays['ohlc']) + 666: + if ev.delta() < 0 and vl >= len(chart._arrays[chart.name]) + 666: log.debug("Min zoom bruh...") return @@ -432,67 +459,89 @@ class ChartView(ViewBox): s = 1.015 ** (ev.delta() * -1 / 20) # self.state['wheelScaleFactor']) s = [(None if m is False else s) for m in mask] - # center = pg.Point( - # fn.invertQTransform(self.childGroup.transform()).map(ev.pos()) - # ) + if ( + # zoom happened on axis + axis == 1 - # XXX: scroll "around" the right most element in the view - # which stays "pinned" in place. + # if already in axis zoom mode then keep it + or self.chart._static_yrange == 'axis' + ): + self.chart._static_yrange = 'axis' + self.setLimits(yMin=None, yMax=None) - # furthest_right_coord = self.boundingRect().topRight() + # print(scale_y) + # pos = ev.pos() + # lastPos = ev.lastPos() + # dif = pos - lastPos + # dif = dif * -1 + center = Point(fn.invertQTransform(self.childGroup.transform()).map(ev.pos())) + # scale_y = 1.3 ** (center.y() * -1 / 20) + self.scaleBy(s, center) - # yaxis = pg.Point( - # fn.invertQTransform( - # self.childGroup.transform() - # ).map(furthest_right_coord) - # ) + else: - # This seems like the most "intuitive option, a hybrid of - # tws and tv styles - last_bar = pg.Point(int(rbar)) + 1 + # center = pg.Point( + # fn.invertQTransform(self.childGroup.transform()).map(ev.pos()) + # ) - ryaxis = chart.getAxis('right') - r_axis_x = ryaxis.pos().x() + # XXX: scroll "around" the right most element in the view + # which stays "pinned" in place. - end_of_l1 = pg.Point( - round( - chart._vb.mapToView( - pg.Point(r_axis_x - chart._max_l1_line_len) - # QPointF(chart._max_l1_line_len, 0) - ).x() + # furthest_right_coord = self.boundingRect().topRight() + + # yaxis = pg.Point( + # fn.invertQTransform( + # self.childGroup.transform() + # ).map(furthest_right_coord) + # ) + + # This seems like the most "intuitive option, a hybrid of + # tws and tv styles + last_bar = pg.Point(int(rbar)) + 1 + + ryaxis = chart.getAxis('right') + r_axis_x = ryaxis.pos().x() + + end_of_l1 = pg.Point( + round( + chart.cv.mapToView( + pg.Point(r_axis_x - chart._max_l1_line_len) + # QPointF(chart._max_l1_line_len, 0) + ).x() + ) + ) # .x() + + # self.state['viewRange'][0][1] = end_of_l1 + # focal = pg.Point((last_bar.x() + end_of_l1)/2) + + focal = min( + last_bar, + end_of_l1, + key=lambda p: p.x() ) - ) # .x() + # focal = pg.Point(last_bar.x() + end_of_l1) - # self.state['viewRange'][0][1] = end_of_l1 - - # focal = pg.Point((last_bar.x() + end_of_l1)/2) - - focal = min( - last_bar, - end_of_l1, - key=lambda p: p.x() - ) - # focal = pg.Point(last_bar.x() + end_of_l1) - - self._resetTarget() - self.scaleBy(s, focal) - ev.accept() - self.sigRangeChangedManually.emit(mask) + self._resetTarget() + self.scaleBy(s, focal) + self.sigRangeChangedManually.emit(mask) + ev.accept() def mouseDragEvent( self, ev, axis: Optional[int] = None, + relayed_from: ChartView = None, + ) -> None: - # if axis is specified, event will only affect that axis. - ev.accept() # we accept all buttons - button = ev.button() pos = ev.pos() lastPos = ev.lastPos() dif = pos - lastPos dif = dif * -1 + # NOTE: if axis is specified, event will only affect that axis. + button = ev.button() + # Ignore axes if mouse is disabled mouseEnabled = np.array(self.state['mouseEnabled'], dtype=np.float) mask = mouseEnabled.copy() @@ -500,21 +549,28 @@ class ChartView(ViewBox): mask[1-axis] = 0.0 # Scale or translate based on mouse button - if button & (QtCore.Qt.LeftButton | QtCore.Qt.MidButton): - + if button & ( + QtCore.Qt.LeftButton | QtCore.Qt.MidButton + ): # zoom y-axis ONLY when click-n-drag on it - if axis == 1: - # set a static y range special value on chart widget to - # prevent sizing to data in view. - self.chart._static_yrange = 'axis' + # if axis == 1: + # # set a static y range special value on chart widget to + # # prevent sizing to data in view. + # self.chart._static_yrange = 'axis' - scale_y = 1.3 ** (dif.y() * -1 / 20) - self.setLimits(yMin=None, yMax=None) + # scale_y = 1.3 ** (dif.y() * -1 / 20) + # self.setLimits(yMin=None, yMax=None) - # print(scale_y) - self.scaleBy((0, scale_y)) + # # print(scale_y) + # self.scaleBy((0, scale_y)) - if self.state['mouseMode'] == ViewBox.RectMode: + # SELECTION MODE + if ( + self.state['mouseMode'] == ViewBox.RectMode + and axis is None + ): + # XXX: WHY + ev.accept() down_pos = ev.buttonDownPos() @@ -523,23 +579,36 @@ class ChartView(ViewBox): self.select_box.mouse_drag_released(down_pos, pos) - # ax = QtCore.QRectF(down_pos, pos) - # ax = self.childGroup.mapRectFromParent(ax) - # print(ax) + ax = QtCore.QRectF(down_pos, pos) + ax = self.childGroup.mapRectFromParent(ax) # this is the zoom transform cmd - # self.showAxRect(ax) + self.showAxRect(ax) + + # axis history tracking + self.axHistoryPointer += 1 + self.axHistory = self.axHistory[ + :self.axHistoryPointer] + [ax] - # self.axHistoryPointer += 1 - # self.axHistory = self.axHistory[ - # :self.axHistoryPointer] + [ax] else: + print('drag finish?') self.select_box.set_pos(down_pos, pos) # update shape of scale box # self.updateScaleBox(ev.buttonDownPos(), ev.pos()) + self.updateScaleBox( + down_pos, + ev.pos(), + ) + + # PANNING MODE else: - # default bevavior: click to pan view + # XXX: WHY + ev.accept() + + if axis == 1: + self.chart._static_yrange = 'axis' + tr = self.childGroup.transform() tr = fn.invertQTransform(tr) tr = tr.map(dif*mask) - tr.map(Point(0, 0)) @@ -554,10 +623,9 @@ class ChartView(ViewBox): self.sigRangeChangedManually.emit(self.state['mouseEnabled']) + # WEIRD "RIGHT-CLICK CENTER ZOOM" MODE elif button & QtCore.Qt.RightButton: - # right click zoom to center behaviour - if self.state['aspectLocked'] is not False: mask[0] = 0 @@ -577,6 +645,9 @@ class ChartView(ViewBox): self.scaleBy(x=x, y=y, center=center) self.sigRangeChangedManually.emit(self.state['mouseEnabled']) + # XXX: WHY + ev.accept() + # def mouseClickEvent(self, event: QtCore.QEvent) -> None: # '''This routine is rerouted to an async handler. # ''' @@ -591,3 +662,107 @@ class ChartView(ViewBox): '''This routine is rerouted to an async handler. ''' pass + + def _set_yrange( + self, + *, + + yrange: Optional[tuple[float, float]] = None, + range_margin: float = 0.06, + bars_range: Optional[tuple[int, int, int, int]] = None, + + # flag to prevent triggering sibling charts from the same linked + # set from recursion errors. + autoscale_linked_plots: bool = True, + autoscale_overlays: bool = False, + + ) -> None: + ''' + Set the viewable y-range based on embedded data. + + This adds auto-scaling like zoom on the scroll wheel such + that data always fits nicely inside the current view of the + data set. + + ''' + set_range = True + chart = self._chart + + # view has been set in 'axis' mode + # meaning it can be panned and zoomed + # arbitrarily on the y-axis: + # - disable autoranging + # - remove any y range limits + if chart._static_yrange == 'axis': + set_range = False + self.setLimits(yMin=None, yMax=None) + + # static y-range has been set likely by + # a specialized FSP configuration. + elif chart._static_yrange is not None: + ylow, yhigh = chart._static_yrange + + # range passed in by caller, usually a + # maxmin detection algos inside the + # display loop for re-draw efficiency. + elif yrange is not None: + ylow, yhigh = yrange + + # calculate max, min y values in viewable x-range from data. + # Make sure min bars/datums on screen is adhered. + else: + br = bars_range or chart.bars_range() + + # TODO: maybe should be a method on the + # chart widget/item? + if autoscale_linked_plots: + # avoid recursion by sibling plots + linked = self.linkedsplits + plots = list(linked.subplots.copy().values()) + main = linked.chart + if main: + plots.append(main) + + for chart in plots: + if chart and not chart._static_yrange: + chart.cv._set_yrange( + bars_range=br, + autoscale_linked_plots=False, + ) + + if set_range: + ylow, yhigh = self._maxmin() + + # view margins: stay within a % of the "true range" + diff = yhigh - ylow + ylow = ylow - (diff * range_margin) + yhigh = yhigh + (diff * range_margin) + + # XXX: this often needs to be unset + # to get different view modes to operate + # correctly! + self.setLimits( + yMin=ylow, + yMax=yhigh, + ) + self.setYRange(ylow, yhigh) + + def enable_auto_yrange( + vb: ChartView, + + ) -> None: + ''' + Assign callback for rescaling y-axis automatically + based on data contents and ``ViewBox`` state. + + ''' + vb.sigXRangeChanged.connect(vb._set_yrange) + # mouse wheel doesn't emit XRangeChanged + vb.sigRangeChangedManually.connect(vb._set_yrange) + vb.sigResized.connect(vb._set_yrange) # splitter(s) resizing + + def disable_auto_yrange( + self, + ) -> None: + + self._chart._static_yrange = 'axis' diff --git a/piker/ui/_overlay.py b/piker/ui/_overlay.py new file mode 100644 index 00000000..256909bd --- /dev/null +++ b/piker/ui/_overlay.py @@ -0,0 +1,631 @@ +# 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 typing import Callable, Optional + +from pyqtgraph.Qt.QtCore import ( + # QObject, + # Signal, + Qt, + # QEvent, +) + +from pyqtgraph.graphicsItems.AxisItem import AxisItem +from pyqtgraph.graphicsItems.ViewBox import ViewBox +from pyqtgraph.graphicsItems.GraphicsWidget import GraphicsWidget +from pyqtgraph.graphicsItems.PlotItem.PlotItem import PlotItem +from pyqtgraph.Qt.QtCore import QObject, Signal, QEvent +from pyqtgraph.Qt.QtWidgets import QGraphicsGridLayout, QGraphicsLinearLayout + +from ._interaction import ChartView + +__all__ = ["PlotItemOverlay"] + + +# 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('`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: + # TODO: re-order using ``.pop()`` ? + 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 + + linlayout.insertItem(index, axis) + 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. + + ''' + raise NotImplementedError + + +# Unimplemented features TODO: +# - 'A' (autobtn) should relay to all views +# - context menu single handler + relay? +# - layout unwind and re-pack for 'left' and 'top' axes +# - add labels to layout if detected in source ``PlotItem`` + +# UX nice-to-have TODO: +# - optional "focussed" view box support for view boxes +# that have custom input handlers (eg. you might want to +# scale the view to some "focussed" data view and have overlayed +# viewboxes only respond to relayed events.) +# - figure out how to deal with menu raise events for multi-viewboxes. +# (we might want to add a different menu which specs the name of the +# view box currently being handled? +# - allow selection of a particular view box by interacting with its +# axis? + + +# TODO: we might want to enabled some kind of manual flag to disable +# this method wrapping during type creation? As example a user could +# definitively decide **not** to enable broadcasting support by +# setting something like ``ViewBox.disable_relays = True``? +def mk_relay_method( + + signame: str, + slot: Callable[ + [ViewBox, + 'QEvent', + Optional[AxisItem]], + None, + ], + +) -> Callable[ + [ + ViewBox, + # lol, there isn't really a generic type thanks + # to the rewrite of Qt's event system XD + 'QEvent', + + 'Optional[AxisItem]', + 'Optional[ViewBox]', # the ``relayed_from`` arg we provide + ], + None, +]: + + def maybe_broadcast( + vb: 'ViewBox', + ev: 'QEvent', + axis: 'Optional[int]' = None, + relayed_from: 'ViewBox' = None, + + ) -> None: + ''' + (soon to be) Decorator which makes an event handler + "broadcastable" to overlayed ``GraphicsWidget``s. + + Adds relay signals based on the decorated handler's name + and conducts a signal broadcast of the relay signal if there + are consumers registered. + + ''' + # When no relay source has been set just bypass all + # the broadcast machinery. + if vb.event_relay_source is None: + ev.accept() + return slot( + vb, + ev, + axis=axis, + ) + + if relayed_from: + assert axis is None + + # this is a relayed event and should be ignored (so it does not + # halt/short circuit the graphicscene loop). Further the + # surrounding handler for this signal must be allowed to execute + # and get processed by **this consumer**. + print(f'{vb.name} rx relayed from {relayed_from.name}') + ev.ignore() + + return slot( + vb, + ev, + axis=axis, + ) + + if axis is not None: + print(f'{vb.name} handling axis event:\n{str(ev)}') + ev.accept() + return slot( + vb, + ev, + axis=axis, + ) + + elif ( + relayed_from is None + and vb.event_relay_source is vb # we are the broadcaster + and axis is None + ): + # Broadcast case: this is a source event which will be + # relayed to attached consumers and accepted after all + # consumers complete their own handling followed by this + # routine's processing. Sequence is, + # - pre-relay to all consumers *first* - ``.emit()`` blocks + # until all downstream relay handlers have run. + # - run the source handler for **this** event and accept + # the event + + # Access the "bound signal" that is created + # on the widget type as part of instantiation. + signal = getattr(vb, signame) + # print(f'{vb.name} emitting {signame}') + + # TODO/NOTE: we could also just bypass a "relay" signal + # entirely and instead call the handlers manually in + # a loop? This probably is a lot simpler and also doesn't + # have any downside, and allows not touching target widget + # internals. + signal.emit( + ev, + axis, + # passing this demarks a broadcasted/relayed event + vb, + ) + # accept event so no more relays are fired. + ev.accept() + + # call underlying wrapped method with an extra + # ``relayed_from`` value to denote that this is a relayed + # event handling case. + return slot( + vb, + ev, + axis=axis, + ) + + return maybe_broadcast + + +# XXX: :( can't define signals **after** class compile time +# so this is not really useful. +# def mk_relay_signal( +# func, +# name: str = None, + +# ) -> Signal: +# ( +# args, +# varargs, +# varkw, +# defaults, +# kwonlyargs, +# kwonlydefaults, +# annotations +# ) = inspect.getfullargspec(func) + +# # XXX: generate a relay signal with 1 extra +# # argument for a ``relayed_from`` kwarg. Since +# # ``'self'`` is already ignored by signals we just need +# # to count the arguments since we're adding only 1 (and +# # ``args`` will capture that). +# numargs = len(args + list(defaults)) +# signal = Signal(*tuple(numargs * [object])) +# signame = name or func.__name__ + 'Relay' +# return signame, signal + + +def enable_relays( + widget: GraphicsWidget, + handler_names: list[str], + +) -> list[Signal]: + ''' + Method override helper which enables relay of a particular + ``Signal`` from some chosen broadcaster widget to a set of + consumer widgets which should operate their event handlers normally + but instead of signals "relayed" from the broadcaster. + + Mostly useful for overlaying widgets that handle user input + that you want to overlay graphically. The target ``widget`` type must + define ``QtCore.Signal``s each with a `'Relay'` suffix for each + name provided in ``handler_names: list[str]``. + + ''' + signals = [] + for name in handler_names: + handler = getattr(widget, name) + signame = name + 'Relay' + # ensure the target widget defines a relay signal + relay = getattr(widget, signame) + widget.relays[signame] = name + signals.append(relay) + method = mk_relay_method(signame, handler) + setattr(widget, name, method) + + return signals + + +enable_relays( + ChartView, + ['wheelEvent', 'mouseDragEvent'] +) + + +class PlotItemOverlay: + ''' + A composite for managing overlaid ``PlotItem`` instances such that + you can make multiple graphics appear on the same graph with + separate (non-colliding) axes apply ``ViewBox`` signal broadcasting + such that all overlaid items respond to input simultaneously. + + ''' + def __init__( + self, + root_plotitem: PlotItem + + ) -> None: + + self.root_plotitem: PlotItem = root_plotitem + + vb = root_plotitem.vb + vb.event_relay_source = vb # TODO: maybe change name? + vb.setZValue(1000) # XXX: critical for scene layering/relaying + + self.overlays: list[PlotItem] = [] + from piker.ui._overlay import ComposedGridLayout + self.layout = ComposedGridLayout( + root_plotitem, + root_plotitem.layout, + ) + self._relays: dict[str, Signal] = {} + + def add_plotitem( + self, + plotitem: PlotItem, + index: Optional[int] = None, + + # TODO: we could also put the ``ViewBox.XAxis`` + # style enum here? + # (0,), # link x + # (1,), # link y + # (0, 1), # link both + link_axes: tuple[int] = (), + + ) -> None: + + index = index or 0 + root = self.root_plotitem + # layout: QGraphicsGridLayout = root.layout + self.overlays.insert(index, plotitem) + vb: ViewBox = plotitem.vb + + # mark this consumer overlay as ready to expect relayed events + # from the root plotitem. + vb.event_relay_source = root.vb + + # TODO: some sane way to allow menu event broadcast XD + # vb.setMenuEnabled(False) + + # TODO: inside the `maybe_broadcast()` (soon to be) decorator + # we need have checks that consumers have been attached to + # these relay signals. + if link_axes != (0, 1): + + # wire up relay signals + for relay_signal_name, handler_name in vb.relays.items(): + # print(handler_name) + # XXX: Signal class attrs are bound after instantiation + # of the defining type, so we need to access that bound + # version here. + signal = getattr(root.vb, relay_signal_name) + handler = getattr(vb, handler_name) + signal.connect(handler) + + # link dim-axes to root if requested by user. + # TODO: solve more-then-wanted scaled panning on click drag + # which seems to be due to broadcast. So we probably need to + # disable broadcast when axes are linked in a particular + # dimension? + for dim in link_axes: + # link x and y axes to new view box such that the top level + # viewbox propagates to the root (and whatever other + # plotitem overlays that have been added). + vb.linkView(dim, root.vb) + + # make overlaid viewbox impossible to focus since the top + # level should handle all input and relay to overlays. + # NOTE: this was solved with the `setZValue()` above! + + # TODO: we will probably want to add a "focus" api such that + # a new "top level" ``PlotItem`` can be selected dynamically + # (and presumably the axes dynamically sorted to match). + vb.setFlag( + vb.GraphicsItemFlag.ItemIsFocusable, + False + ) + vb.setFocusPolicy(Qt.NoFocus) + + # append-compose into the layout all axes from this plot + self.layout.insert(index, plotitem) + + plotitem.setGeometry(root.vb.sceneBoundingRect()) + + def size_to_viewbox(vb: 'ViewBox'): + plotitem.setGeometry(vb.sceneBoundingRect()) + + root.vb.sigResized.connect(size_to_viewbox) + + # ensure the overlayed view is redrawn on each cycle + root.scene().sigPrepareForPaint.connect(vb.prepareForPaint) + + # focus state sanity + vb.clearFocus() + assert not vb.focusWidget() + root.vb.setFocus() + assert root.vb.focusWidget() + + # XXX: do we need this? Why would you build then destroy? + def remove_plotitem(self, plotItem: PlotItem) -> None: + ''' + Remove this ``PlotItem`` from the overlayed set making not shown + and unable to accept input. + + ''' + ... + + # TODO: i think this would be super hot B) + def focus_item(self, plotitem: PlotItem) -> PlotItem: + ''' + Apply focus to a contained PlotItem thus making it the "top level" + item in the overlay able to accept peripheral's input from the user + and responsible for zoom and panning control via its ``ViewBox``. + + ''' + ... + + def get_axis( + self, + plot: PlotItem, + name: str, + + ) -> AxisItem: + ''' + Retrieve the named axis for overlayed ``plot``. + + ''' + return self.layout.get_axis(plot, name) + + # TODO: i guess we need this if you want to detach existing plots + # dynamically? XXX: untested as of now. + def _disconnect_all( + self, + plotitem: PlotItem, + ) -> list[Signal]: + ''' + Disconnects all signals related to this widget for the given chart. + + ''' + disconnected = [] + for pi, sig in self._relays.items(): + QObject.disconnect(sig) + disconnected.append(sig) + + return disconnected