From 9e18afe0d721dbd90aff890a4690bac996b4321e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 24 Jan 2022 15:06:51 -0500 Subject: [PATCH 01/14] WIP `PlotItemOverlay` support to get multi-yaxes This syncs with a dev branch in our `pyqtgraph` fork: https://github.com/pyqtgraph/pyqtgraph/pull/2162 The main idea is to get mult-yaxis display fully functional with multiple view boxes running in a "relay mode" where some focussed view relays signals to overlaid views which may have independent axes. This preps us for both displaying independent codomain-set FSP output as well as so called "aggregate" feeds of multiple fins underlyings on the same chart (eg. options and futures over top of ETFs and underlying stocks). The eventual desired UX is to support fast switching of instruments for order mode trading without requiring entirely separate charts as well as simple real-time anal of associated instruments. The first effort here is to display vlm and $_vlm alongside each other as a built-in FSP subchart. --- piker/ui/_chart.py | 230 +++++++++++++++++++++++++++++---------------- 1 file changed, 149 insertions(+), 81 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 6d4ebc83..c9272c47 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -322,17 +322,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 +401,6 @@ class LinkedSplits(QWidget): name=symbol.key, array=array, - # xaxis=self.xaxis, style=style, _is_main=True, @@ -420,7 +410,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 +431,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 +438,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 +475,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 + axis = 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 +505,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 +522,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 +550,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 +573,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 @@ -611,6 +631,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 +663,28 @@ 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 + self.useOpenGL(use_open_gl) self.name = name self.data_key = data_key - self.linked = linkedsplits # scene-local placeholder for book graphics # sizing to avoid overlap with data contents @@ -687,13 +722,12 @@ class ChartPlotWidget(pg.PlotWidget): # Assign callback for rescaling y-axis automatically # based on data contents and ``ViewBox`` state. - # self.sigXRangeChanged.connect(self._set_yrange) + self.sigXRangeChanged.connect(self._set_yrange) + self._vb.sigRangeChangedManually.connect(self._set_yrange) # mouse wheel doesn't emit XRangeChanged + self._vb.sigResized.connect(self._set_yrange) # splitter(s) resizing - # 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) + from pyqtgraph.widgets._plotitemoverlay import PlotItemOverlay + self.overlay: PlotItemOverlay = PlotItemOverlay(self.plotItem) def resume_all_feeds(self): for feed in self._feeds.values(): @@ -791,11 +825,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 +844,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 +856,18 @@ class ChartPlotWidget(pg.PlotWidget): array_key: Optional[str] = None, overlay: bool = False, + separate_axes: bool = True, 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 +875,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,8 +905,6 @@ 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 @@ -891,24 +913,69 @@ class ChartPlotWidget(pg.PlotWidget): anchor_at = ('bottom', 'left') self._overlays[name] = None + if separate_axes: + # Custom viewbox impl + cv = self.mk_vb(name) + cv.chart = self + # cv.enableAutoRange(axis=1) + + 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, + }, + ) + # plotitem.setAxisItems( + # add_to_layout=False, + # axisItems={ + # 'bottom': xaxis, + # 'right': yaxis, + # }, + # ) + # plotite.hideAxis('right') + # plotite.hideAxis('bottom') + plotitem.addItem(curve) + + # config + plotitem.enableAutoRange(axis='y') + plotitem.setAutoVisible(y=True) + 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: + # 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( @@ -1005,7 +1072,8 @@ class ChartPlotWidget(pg.PlotWidget): autoscale_linked_plots: bool = True, ) -> None: - '''Set the viewable y-range based on embedded data. + ''' + 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 From 1ccff376773a1fb4441ef0f207a55be05b2b4efb Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 24 Jan 2022 15:14:06 -0500 Subject: [PATCH 02/14] Only update x-axis cursor if chart has a 'bottom' axis --- piker/ui/_cursor.py | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/piker/ui/_cursor.py b/piker/ui/_cursor.py index fd9df0f0..cac2f0b9 100644 --- a/piker/ui/_cursor.py +++ b/piker/ui/_cursor.py @@ -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, @@ -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 From 3225f254f4ac24d1dd3726c178960d08586e54af Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 24 Jan 2022 15:19:45 -0500 Subject: [PATCH 03/14] Explicitly accept interaction events in our chart view --- piker/ui/_interaction.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 9f33253d..6fba5070 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 @@ -399,7 +400,12 @@ class ChartView(ViewBox): self._chart = chart self.select_box.chart = chart - 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 @@ -476,16 +482,16 @@ class ChartView(ViewBox): self._resetTarget() self.scaleBy(s, focal) - ev.accept() 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() @@ -514,7 +520,10 @@ class ChartView(ViewBox): # print(scale_y) self.scaleBy((0, scale_y)) + # SELECTION MODE if self.state['mouseMode'] == ViewBox.RectMode: + # XXX: WHY + ev.accept() down_pos = ev.buttonDownPos() @@ -538,8 +547,11 @@ class ChartView(ViewBox): # update shape of scale box # self.updateScaleBox(ev.buttonDownPos(), ev.pos()) + + # PANNING MODE else: - # default bevavior: click to pan view + # XXX: WHY + ev.accept() tr = self.childGroup.transform() tr = fn.invertQTransform(tr) tr = tr.map(dif*mask) - tr.map(Point(0, 0)) @@ -554,10 +566,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 +588,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. # ''' From 65c3cc5f5fb7d4e3d94af947f72a78208a94715b Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 24 Jan 2022 15:21:05 -0500 Subject: [PATCH 04/14] Don't use separate axes by default, force empty default axes set --- piker/ui/_chart.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index c9272c47..b1bdd345 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -856,7 +856,7 @@ class ChartPlotWidget(pg.PlotWidget): array_key: Optional[str] = None, overlay: bool = False, - separate_axes: bool = True, + separate_axes: bool = False, color: Optional[str] = None, add_label: bool = True, @@ -937,6 +937,7 @@ class ChartPlotWidget(pg.PlotWidget): # 'bottom': xaxis, 'right': yaxis, }, + default_axes=[], ) # plotitem.setAxisItems( # add_to_layout=False, @@ -950,8 +951,8 @@ class ChartPlotWidget(pg.PlotWidget): plotitem.addItem(curve) # config - plotitem.enableAutoRange(axis='y') - plotitem.setAutoVisible(y=True) + # plotitem.enableAutoRange(axis='y') + # plotitem.setAutoVisible(y=True) plotitem.hideButtons() self.overlay.add_plotitem( From 6f07c5e2552472485d477c6495d3ac67d636a51d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 9 Jan 2022 11:31:15 -0500 Subject: [PATCH 05/14] Drop 'ohlc' array usage from UI components --- piker/ui/_axes.py | 27 ++++++++++++++++++++------- piker/ui/_cursor.py | 4 ++-- piker/ui/_editors.py | 3 ++- 3 files changed, 24 insertions(+), 10 deletions(-) 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/_cursor.py b/piker/ui/_cursor.py index cac2f0b9..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( @@ -438,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) 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() From 12e04d57f877a840998e354705d017a8b02573ac Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 21 Jan 2022 07:22:33 -0500 Subject: [PATCH 06/14] Add a "composed" layout for arbitrary multi-axes Each `pyqtgraph.PlotItem` uses a `QGraphicsGridLayout` to place its view box, axes and titles in the traditional graph format. With multiple overlayed charts we need those axes to not collide with one another and further allow for an "order" specified by the user. We accomplish this by adding `QGraphicsLinearLayout`s for each axis "side": `{'left', 'right', 'top', 'bottom'}` such that plot axes can be inserted and moved easily without having to constantly re-stack/order a grid layout (which does not have a linked-list style API). The new type is called `ComposedGridLayout` for now and offers a basic list-like API with `.insert()`, `.append()`, and eventually a dict-style `.pop()`. We probably want to also eventually offer a `.focus()` to allow user switching of *which* main graphics object (aka chart) is "in use". --- piker/ui/_overlay.py | 272 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 piker/ui/_overlay.py 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. + + ''' + ... From 637c9c65e90339c552e8eaaa4b3c23f6c4468e10 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 21 Jan 2022 08:34:18 -0500 Subject: [PATCH 07/14] Drop old grid-based overlay cruft --- piker/ui/_overlay.py | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/piker/ui/_overlay.py b/piker/ui/_overlay.py index fd22e8e5..e20b69f4 100644 --- a/piker/ui/_overlay.py +++ b/piker/ui/_overlay.py @@ -175,6 +175,7 @@ class ComposedGridLayout: axis = axis_info['item'] if axis in axes: + # TODO: re-order using ``.pop()`` ? ValueError(f'{axis} is already in {name} layout!?') # linking sanity @@ -206,27 +207,7 @@ class ComposedGridLayout: 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) @@ -269,4 +250,4 @@ class ComposedGridLayout: Remove item and restack all axes in list-order. ''' - ... + raise NotImplemented From 4b89f7197a526ecabaf43b460fd290342fde6065 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 21 Jan 2022 16:19:25 -0500 Subject: [PATCH 08/14] Move multi-view overlay components in from `pyqtgraph` PR This brings in the WIP components developed as part of https://github.com/pyqtgraph/pyqtgraph/pull/2162. Most of the history can be understood from that issue and effort but the TL;DR is, - add an event handler wrapper system which can be used to wrap `ViewBox` methods such that multiple views can be overlayed and a single event stream broadcast from one "main" view to others which are overlaid with it. - add in 2 relay `Signal` attrs to our `ViewBox` subtype (`Chartview`) to accomplish per event `MouseEvent.emit()` style broadcasting to multiple (sub-)views. - Add a `PlotItemOverlay` api which does all the work of overlaying the actual chart graphics and arranging multiple-axes without collision as well as tying together all the event/signalling so that only a single "focussed" view relays to all overlays. --- piker/ui/_overlay.py | 384 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 381 insertions(+), 3 deletions(-) diff --git a/piker/ui/_overlay.py b/piker/ui/_overlay.py index e20b69f4..256909bd 100644 --- a/piker/ui/_overlay.py +++ b/piker/ui/_overlay.py @@ -18,17 +18,27 @@ 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.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 @@ -166,7 +176,7 @@ class ComposedGridLayout: ''' if index < 0: - raise ValueError(f'`insert()` only supports an 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. @@ -250,4 +260,372 @@ class ComposedGridLayout: Remove item and restack all axes in list-order. ''' - raise NotImplemented + 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 From e66b3792bb96e4f7445ce9503d3a6fd9690e5fa2 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 21 Jan 2022 17:02:08 -0500 Subject: [PATCH 09/14] Add overlay relay signals and attr to our `ChartView` --- piker/ui/_interaction.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 6fba5070..8cd8cf98 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -333,6 +333,16 @@ 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, From 80d16886cb2ce73a673d768420b6cd1237ab6883 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 8 Jan 2022 17:52:16 -0500 Subject: [PATCH 10/14] Add auto-yrange handler to our `ChartView` Calculations for auto-yaxis ranging are both signalled and drawn by our `ViewBox` so we might as well factor this handler down from the chart widget into the view type. This makes it much easier (and clearer) that `PlotItem` and other lower level overlayed `GraphicsObject`s can utilize *size-to-data* style view modes easily without widget-level coupling. Further changes, - support a `._maxmin()` internal callable (temporarily) for allowing a viewed graphics object to define it's own y-range max/min calc. - add `._static_range` var (though usage hasn't been moved from the chart plot widget yet - drop y-axis click-drag zoom instead reverting back to default viewbox behaviour with wheel-zoom and click-drag-pan on the axis. --- piker/ui/_interaction.py | 271 ++++++++++++++++++++++++++++++--------- 1 file changed, 211 insertions(+), 60 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 8cd8cf98..4168a3ff 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -156,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() @@ -349,6 +350,7 @@ class ChartView(ViewBox): name: str, parent: pg.PlotItem = None, + static_yrange: Optional[tuple[float, float]] = None, **kwargs, ): @@ -361,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 @@ -409,6 +418,8 @@ 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, @@ -440,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 @@ -448,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) - self.sigRangeChangedManually.emit(mask) - ev.accept() + 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. - 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() @@ -516,22 +549,26 @@ 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)) # SELECTION MODE - if self.state['mouseMode'] == ViewBox.RectMode: + if ( + self.state['mouseMode'] == ViewBox.RectMode + and axis is None + ): # XXX: WHY ev.accept() @@ -542,26 +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: # 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)) @@ -615,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' From fbb765e1d86ca752138f32b620aad6c416f4eee9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 21 Jan 2022 17:02:44 -0500 Subject: [PATCH 11/14] Import overlay from new internal module --- piker/ui/_chart.py | 1 + 1 file changed, 1 insertion(+) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index b1bdd345..5f1e352f 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -61,6 +61,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__) From ced310c194e56b08058db9f346f4548184130b26 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 9 Jan 2022 10:34:38 -0500 Subject: [PATCH 12/14] Add `ChartPlotWidget.maxmin()` to calc in-view hi/lo y-values As part of factoring `._set_yrange()` into the lower level view box, move the y-range calculations into a new method. These calcs should eventually be completely separate (as they are for the real-time version in the graphics display update loop) and likely part of some kind of graphics-related lower level management API. Draft such an API as an `ArrayScene` (commented for now) as a sketch toward factoring array tracking **out of** the chart widget. Drop the `'ohlc'` array name and instead always use whatever `.name` was assigned to the chart widget to lookup its "main" / source data array for now. Enable auto-yranging on overlayed plotitems by enabling on its viewbox and, for now, assign an ad-hoc `._maxmin()` since the widget version from this commit has no easy way to know which internal array to use. If an FSP (`dolla_vlm` in this case) is overlayed on an existing chart without also having a full widget (which it doesn't in this case since we're using an overlayed `PlotItem` instead of a full `ChartPlotWidget`) we need some way to define the `.maxmin()` for the overlayed data/graphics. This likely means the `.maxmin()` will eventually get factored into wtv lowlevel `ArrayScene` API mentioned above. --- piker/ui/_chart.py | 241 +++++++++++++++++++++------------------------ 1 file changed, 111 insertions(+), 130 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 5f1e352f..1fec22c7 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,10 +31,10 @@ from PyQt5.QtWidgets import ( QHBoxLayout, QVBoxLayout, QSplitter, - # QSizePolicy, ) import numpy as np import pyqtgraph as pg +from pyqtgraph.widgets._plotitemoverlay import PlotItemOverlay import trio from ._axes import ( @@ -484,7 +486,7 @@ class LinkedSplits(QWidget): # 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 - axis = self.xaxis_chart.removeAxis('bottom', unlink=False) + _ = self.xaxis_chart.removeAxis('bottom', unlink=False) assert 'bottom' not in self.xaxis_chart.plotItem.axes self.xaxis_chart = cpw @@ -608,6 +610,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): ''' @@ -683,9 +697,12 @@ class ChartPlotWidget(pg.PlotWidget): # (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.data_key = data_key or name # scene-local placeholder for book graphics # sizing to avoid overlap with data contents @@ -694,9 +711,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 @@ -707,7 +725,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' @@ -720,14 +737,8 @@ 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) - self._vb.sigRangeChangedManually.connect(self._set_yrange) # mouse wheel doesn't emit XRangeChanged - self._vb.sigResized.connect(self._set_yrange) # splitter(s) resizing - - from pyqtgraph.widgets._plotitemoverlay import PlotItemOverlay self.overlay: PlotItemOverlay = PlotItemOverlay(self.plotItem) def resume_all_feeds(self): @@ -740,16 +751,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, @@ -773,7 +784,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 @@ -785,7 +796,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 @@ -793,12 +804,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, @@ -809,7 +821,7 @@ class ChartPlotWidget(pg.PlotWidget): """ l, r = self.view_range() - self._vb.setXRange( + self.view.setXRange( min=l + 1, max=r + 1, @@ -908,22 +920,31 @@ class ChartPlotWidget(pg.PlotWidget): # 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) - cv.chart = self - # cv.enableAutoRange(axis=1) - xaxis = DynamicDateAxis( - orientation='bottom', - linkedsplits=self.linked, - ) + 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, @@ -950,10 +971,11 @@ class ChartPlotWidget(pg.PlotWidget): # plotite.hideAxis('right') # plotite.hideAxis('bottom') plotitem.addItem(curve) + cv.enable_auto_yrange() # config - # plotitem.enableAutoRange(axis='y') # plotitem.setAutoVisible(y=True) + # plotitem.enableAutoRange(axis='y') plotitem.hideButtons() self.overlay.add_plotitem( @@ -971,7 +993,7 @@ class ChartPlotWidget(pg.PlotWidget): # graphics object self.addItem(curve) - anchor_at = ('top', 'left') + # anchor_at = ('top', 'left') # TODO: something instead of stickies for overlays # (we need something that avoids clutter on x-axis). @@ -1018,7 +1040,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 @@ -1039,7 +1061,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 @@ -1061,103 +1083,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 @@ -1210,3 +1135,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 From d170132eb592257fb6a72f175c5e21b3b0601d98 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 9 Jan 2022 11:34:24 -0500 Subject: [PATCH 13/14] Use view for auto-yrange in display loop --- piker/ui/_display.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) 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 From 0ae79d6418c5875f2ad04b887ced58665b47a637 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 24 Jan 2022 16:15:38 -0500 Subject: [PATCH 14/14] Drop `pyqtgraph` import --- piker/ui/_chart.py | 1 - 1 file changed, 1 deletion(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 1fec22c7..3ac2cf14 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -34,7 +34,6 @@ from PyQt5.QtWidgets import ( ) import numpy as np import pyqtgraph as pg -from pyqtgraph.widgets._plotitemoverlay import PlotItemOverlay import trio from ._axes import (