commit
						05b8e3a199
					
				|  | @ -18,6 +18,7 @@ | ||||||
| Chart axes graphics and behavior. | Chart axes graphics and behavior. | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
|  | import functools | ||||||
| from typing import List, Tuple, Optional | from typing import List, Tuple, Optional | ||||||
| from math import floor | from math import floor | ||||||
| 
 | 
 | ||||||
|  | @ -33,17 +34,18 @@ _axis_pen = pg.mkPen(hcolor('bracket')) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class Axis(pg.AxisItem): | 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__( |     def __init__( | ||||||
|         self, |         self, | ||||||
|         linkedsplits, |         linkedsplits, | ||||||
|         typical_max_str: str = '100 000.000', |         typical_max_str: str = '100 000.000', | ||||||
|         min_tick: int = 2, |         min_tick: int = 2, | ||||||
|         **kwargs |         **kwargs | ||||||
|     ) -> None: |  | ||||||
| 
 | 
 | ||||||
|  |     ) -> None: | ||||||
|         super().__init__(**kwargs) |         super().__init__(**kwargs) | ||||||
| 
 | 
 | ||||||
|         # XXX: pretty sure this makes things slower |         # 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 |     # 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 |         # TODO: figure out how to enforce min tick spacing by passing | ||||||
|         # it into the parent type |         # it into the parent type | ||||||
|  | @ -131,9 +138,8 @@ class DynamicDateAxis(Axis): | ||||||
|         indexes: List[int], |         indexes: List[int], | ||||||
|     ) -> List[str]: |     ) -> List[str]: | ||||||
| 
 | 
 | ||||||
|         # try: |  | ||||||
|         chart = self.linkedsplits.chart |         chart = self.linkedsplits.chart | ||||||
|         bars = chart._arrays['ohlc'] |         bars = chart._arrays[chart.name] | ||||||
|         shm = self.linkedsplits.chart._shm |         shm = self.linkedsplits.chart._shm | ||||||
|         first = shm._first.value |         first = shm._first.value | ||||||
| 
 | 
 | ||||||
|  | @ -156,7 +162,14 @@ class DynamicDateAxis(Axis): | ||||||
|         delay = times[-1] - times[-2] |         delay = times[-1] - times[-2] | ||||||
|         return dts.strftime(self.tick_tpl[delay]) |         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) |         return self._indexes_to_timestrs(values) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -19,6 +19,8 @@ High level chart-widget apis. | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  | from functools import partial | ||||||
|  | from dataclasses import dataclass | ||||||
| from typing import Optional | from typing import Optional | ||||||
| 
 | 
 | ||||||
| from PyQt5 import QtCore, QtWidgets | from PyQt5 import QtCore, QtWidgets | ||||||
|  | @ -29,7 +31,6 @@ from PyQt5.QtWidgets import ( | ||||||
|     QHBoxLayout, |     QHBoxLayout, | ||||||
|     QVBoxLayout, |     QVBoxLayout, | ||||||
|     QSplitter, |     QSplitter, | ||||||
|     # QSizePolicy, |  | ||||||
| ) | ) | ||||||
| import numpy as np | import numpy as np | ||||||
| import pyqtgraph as pg | import pyqtgraph as pg | ||||||
|  | @ -61,6 +62,7 @@ from ..data._sharedmem import ShmArray | ||||||
| from ..log import get_logger | from ..log import get_logger | ||||||
| from ._interaction import ChartView | from ._interaction import ChartView | ||||||
| from ._forms import FieldsForm | from ._forms import FieldsForm | ||||||
|  | from ._overlay import PlotItemOverlay | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| log = get_logger(__name__) | log = get_logger(__name__) | ||||||
|  | @ -322,17 +324,8 @@ class LinkedSplits(QWidget): | ||||||
|         self.subplots: dict[tuple[str, ...], ChartPlotWidget] = {} |         self.subplots: dict[tuple[str, ...], ChartPlotWidget] = {} | ||||||
| 
 | 
 | ||||||
|         self.godwidget = godwidget |         self.godwidget = godwidget | ||||||
| 
 |         # placeholder for last appended ``PlotItem``'s bottom axis. | ||||||
|         self.xaxis = DynamicDateAxis( |         self.xaxis_chart = None | ||||||
|             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() |  | ||||||
| 
 | 
 | ||||||
|         self.splitter = QSplitter(QtCore.Qt.Vertical) |         self.splitter = QSplitter(QtCore.Qt.Vertical) | ||||||
|         self.splitter.setMidLineWidth(0) |         self.splitter.setMidLineWidth(0) | ||||||
|  | @ -410,7 +403,6 @@ class LinkedSplits(QWidget): | ||||||
| 
 | 
 | ||||||
|             name=symbol.key, |             name=symbol.key, | ||||||
|             array=array, |             array=array, | ||||||
|             # xaxis=self.xaxis, |  | ||||||
|             style=style, |             style=style, | ||||||
|             _is_main=True, |             _is_main=True, | ||||||
| 
 | 
 | ||||||
|  | @ -420,7 +412,10 @@ class LinkedSplits(QWidget): | ||||||
|         self.chart.addItem(self.cursor) |         self.chart.addItem(self.cursor) | ||||||
| 
 | 
 | ||||||
|         # axis placement |         # axis placement | ||||||
|         if _xaxis_at == 'bottom': |         if ( | ||||||
|  |             _xaxis_at == 'bottom' and | ||||||
|  |             'bottom' in self.chart.plotItem.axes | ||||||
|  |         ): | ||||||
|             self.chart.hideAxis('bottom') |             self.chart.hideAxis('bottom') | ||||||
| 
 | 
 | ||||||
|         # style? |         # style? | ||||||
|  | @ -438,7 +433,6 @@ class LinkedSplits(QWidget): | ||||||
|         array: np.ndarray, |         array: np.ndarray, | ||||||
| 
 | 
 | ||||||
|         array_key: Optional[str] = None, |         array_key: Optional[str] = None, | ||||||
|         # xaxis: Optional[DynamicDateAxis] = None, |  | ||||||
|         style: str = 'line', |         style: str = 'line', | ||||||
|         _is_main: bool = False, |         _is_main: bool = False, | ||||||
| 
 | 
 | ||||||
|  | @ -446,31 +440,28 @@ class LinkedSplits(QWidget): | ||||||
| 
 | 
 | ||||||
|         **cpw_kwargs, |         **cpw_kwargs, | ||||||
| 
 | 
 | ||||||
|     ) -> 'ChartPlotWidget': |     ) -> ChartPlotWidget: | ||||||
|         '''Add (sub)plots to chart widget by key. |         ''' | ||||||
|  |         Add (sub)plots to chart widget by key. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         if self.chart is None and not _is_main: |         if self.chart is None and not _is_main: | ||||||
|             raise RuntimeError( |             raise RuntimeError( | ||||||
|                 "A main plot must be created first with `.plot_ohlc_main()`") |                 "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 |         # use "indicator axis" by default | ||||||
| 
 | 
 | ||||||
|         # TODO: we gotta possibly assign this back |         # TODO: we gotta possibly assign this back | ||||||
|         # to the last subplot on removal of some last subplot |         # to the last subplot on removal of some last subplot | ||||||
| 
 |  | ||||||
|         xaxis = DynamicDateAxis( |         xaxis = DynamicDateAxis( | ||||||
|             orientation='bottom', |             orientation='bottom', | ||||||
|             linkedsplits=self |             linkedsplits=self | ||||||
|         ) |         ) | ||||||
| 
 |         axes = { | ||||||
|         if self.xaxis: |             'right': PriceAxis(linkedsplits=self, orientation='right'), | ||||||
|             self.xaxis.hide() |             'left': PriceAxis(linkedsplits=self, orientation='left'), | ||||||
|             self.xaxis = xaxis |             'bottom': xaxis, | ||||||
|  |         } | ||||||
| 
 | 
 | ||||||
|         qframe = ChartnPane( |         qframe = ChartnPane( | ||||||
|             sidepane=sidepane, |             sidepane=sidepane, | ||||||
|  | @ -486,15 +477,21 @@ class LinkedSplits(QWidget): | ||||||
|             array=array, |             array=array, | ||||||
|             parent=qframe, |             parent=qframe, | ||||||
|             linkedsplits=self, |             linkedsplits=self, | ||||||
|             axisItems={ |             axisItems=axes, | ||||||
|                 'bottom': xaxis, |  | ||||||
|                 'right': PriceAxis(linkedsplits=self, orientation='right'), |  | ||||||
|                 'left': PriceAxis(linkedsplits=self, orientation='left'), |  | ||||||
|             }, |  | ||||||
|             viewBox=cv, |  | ||||||
|             **cpw_kwargs, |             **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.chart = cpw | ||||||
|         qframe.hbox.addWidget(cpw) |         qframe.hbox.addWidget(cpw) | ||||||
| 
 | 
 | ||||||
|  | @ -510,17 +507,13 @@ class LinkedSplits(QWidget): | ||||||
|         ) |         ) | ||||||
|         cpw.sidepane = sidepane |         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.plotItem.vb.linkedsplits = self | ||||||
|         cpw.setFrameStyle( |         cpw.setFrameStyle( | ||||||
|             QtWidgets.QFrame.StyledPanel |             QtWidgets.QFrame.StyledPanel | ||||||
|             # | QtWidgets.QFrame.Plain |             # | QtWidgets.QFrame.Plain | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |         # don't show the little "autoscale" A label. | ||||||
|         cpw.hideButtons() |         cpw.hideButtons() | ||||||
| 
 | 
 | ||||||
|         # XXX: gives us outline on backside of y-axis |         # XXX: gives us outline on backside of y-axis | ||||||
|  | @ -531,15 +524,27 @@ class LinkedSplits(QWidget): | ||||||
|         # comes from ;) |         # comes from ;) | ||||||
|         cpw.setXLink(self.chart) |         cpw.setXLink(self.chart) | ||||||
| 
 | 
 | ||||||
|         # add to cross-hair's known plots |         add_label = False | ||||||
|         self.cursor.add_plot(cpw) |         anchor_at = ('top', 'left') | ||||||
| 
 | 
 | ||||||
|         # draw curve graphics |         # draw curve graphics | ||||||
|         if style == 'bar': |         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': |         elif style == 'line': | ||||||
|             cpw.draw_curve( |             add_label = True | ||||||
|  |             graphics, data_key = cpw.draw_curve( | ||||||
|                 name, |                 name, | ||||||
|                 array, |                 array, | ||||||
|                 array_key=array_key, |                 array_key=array_key, | ||||||
|  | @ -547,7 +552,8 @@ class LinkedSplits(QWidget): | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|         elif style == 'step': |         elif style == 'step': | ||||||
|             cpw.draw_curve( |             add_label = True | ||||||
|  |             graphics, data_key = cpw.draw_curve( | ||||||
|                 name, |                 name, | ||||||
|                 array, |                 array, | ||||||
|                 array_key=array_key, |                 array_key=array_key, | ||||||
|  | @ -569,6 +575,22 @@ class LinkedSplits(QWidget): | ||||||
|         else: |         else: | ||||||
|             assert style == 'bar', 'main chart must be OHLC' |             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() |         self.resize_sidepanes() | ||||||
|         return cpw |         return cpw | ||||||
| 
 | 
 | ||||||
|  | @ -587,6 +609,18 @@ class LinkedSplits(QWidget): | ||||||
|                 cpw.sidepane.setMinimumWidth(sp_w) |                 cpw.sidepane.setMinimumWidth(sp_w) | ||||||
|                 cpw.sidepane.setMaximumWidth(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): | class ChartPlotWidget(pg.PlotWidget): | ||||||
|     ''' |     ''' | ||||||
|  | @ -611,6 +645,10 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
| 
 | 
 | ||||||
|     # TODO: can take a ``background`` color setting - maybe there's |     # TODO: can take a ``background`` color setting - maybe there's | ||||||
|     # a better one? |     # a better one? | ||||||
|  |     def mk_vb(self, name: str) -> ChartView: | ||||||
|  |         cv = ChartView(name) | ||||||
|  |         cv.linkedsplits = self.linked | ||||||
|  |         return cv | ||||||
| 
 | 
 | ||||||
|     def __init__( |     def __init__( | ||||||
|         self, |         self, | ||||||
|  | @ -639,17 +677,31 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
|         self.view_color = view_color |         self.view_color = view_color | ||||||
|         self.pen_color = pen_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__( |         super().__init__( | ||||||
|             background=hcolor(view_color), |             background=hcolor(view_color), | ||||||
|  |             viewBox=cv, | ||||||
|             # parent=None, |             # parent=None, | ||||||
|             # plotItem=None, |             # plotItem=None, | ||||||
|             # antialias=True, |             # antialias=True, | ||||||
|             **kwargs |             **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.useOpenGL(use_open_gl) | ||||||
|         self.name = name |         self.name = name | ||||||
|         self.data_key = data_key |         self.data_key = data_key or name | ||||||
|         self.linked = linkedsplits |  | ||||||
| 
 | 
 | ||||||
|         # scene-local placeholder for book graphics |         # scene-local placeholder for book graphics | ||||||
|         # sizing to avoid overlap with data contents |         # sizing to avoid overlap with data contents | ||||||
|  | @ -658,9 +710,10 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
|         # self.setViewportMargins(0, 0, 0, 0) |         # self.setViewportMargins(0, 0, 0, 0) | ||||||
|         # self._ohlc = array  # readonly view of ohlc data |         # self._ohlc = array  # readonly view of ohlc data | ||||||
| 
 | 
 | ||||||
|  |         # TODO: move to Aggr above XD | ||||||
|         # readonly view of data arrays |         # readonly view of data arrays | ||||||
|         self._arrays = { |         self._arrays = { | ||||||
|             'ohlc': array, |             self.data_key: array, | ||||||
|         } |         } | ||||||
|         self._graphics = {}  # registry of underlying graphics |         self._graphics = {}  # registry of underlying graphics | ||||||
|         # registry of overlay curve names |         # registry of overlay curve names | ||||||
|  | @ -671,7 +724,6 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
|         self._labels = {}  # registry of underlying graphics |         self._labels = {}  # registry of underlying graphics | ||||||
|         self._ysticks = {}  # 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._static_yrange = static_yrange  # for "known y-range style" | ||||||
|         self._view_mode: str = 'follow' |         self._view_mode: str = 'follow' | ||||||
| 
 | 
 | ||||||
|  | @ -684,16 +736,9 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
|         self.showGrid(x=False, y=True, alpha=0.3) |         self.showGrid(x=False, y=True, alpha=0.3) | ||||||
| 
 | 
 | ||||||
|         self.default_view() |         self.default_view() | ||||||
|  |         self.cv.enable_auto_yrange() | ||||||
| 
 | 
 | ||||||
|         # Assign callback for rescaling y-axis automatically |         self.overlay: PlotItemOverlay = PlotItemOverlay(self.plotItem) | ||||||
|         # 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) |  | ||||||
| 
 | 
 | ||||||
|     def resume_all_feeds(self): |     def resume_all_feeds(self): | ||||||
|         for feed in self._feeds.values(): |         for feed in self._feeds.values(): | ||||||
|  | @ -705,16 +750,16 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|     def view(self) -> ChartView: |     def view(self) -> ChartView: | ||||||
|         return self._vb |         return self.plotItem.vb | ||||||
| 
 | 
 | ||||||
|     def focus(self) -> None: |     def focus(self) -> None: | ||||||
|         self._vb.setFocus() |         self.view.setFocus() | ||||||
| 
 | 
 | ||||||
|     def last_bar_in_view(self) -> int: |     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: |     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( |     def _set_xlimits( | ||||||
|         self, |         self, | ||||||
|  | @ -738,7 +783,7 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
|         """Return a range tuple for the bars present in view. |         """Return a range tuple for the bars present in view. | ||||||
|         """ |         """ | ||||||
|         l, r = self.view_range() |         l, r = self.view_range() | ||||||
|         array = self._arrays['ohlc'] |         array = self._arrays[self.name] | ||||||
|         lbar = max(l, array[0]['index']) |         lbar = max(l, array[0]['index']) | ||||||
|         rbar = min(r, array[-1]['index']) |         rbar = min(r, array[-1]['index']) | ||||||
|         return l, lbar, rbar, r |         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. |         """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 |         begin = xlast - _bars_to_left_in_follow_mode | ||||||
|         end = xlast + _bars_from_right_in_follow_mode |         end = xlast + _bars_from_right_in_follow_mode | ||||||
| 
 | 
 | ||||||
|  | @ -758,12 +803,13 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
|         if self._static_yrange == 'axis': |         if self._static_yrange == 'axis': | ||||||
|             self._static_yrange = None |             self._static_yrange = None | ||||||
| 
 | 
 | ||||||
|         self.plotItem.vb.setXRange( |         view = self.view | ||||||
|  |         view.setXRange( | ||||||
|             min=begin, |             min=begin, | ||||||
|             max=end, |             max=end, | ||||||
|             padding=0, |             padding=0, | ||||||
|         ) |         ) | ||||||
|         self._set_yrange() |         view._set_yrange() | ||||||
| 
 | 
 | ||||||
|     def increment_view( |     def increment_view( | ||||||
|         self, |         self, | ||||||
|  | @ -774,7 +820,7 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
| 
 | 
 | ||||||
|         """ |         """ | ||||||
|         l, r = self.view_range() |         l, r = self.view_range() | ||||||
|         self._vb.setXRange( |         self.view.setXRange( | ||||||
|             min=l + 1, |             min=l + 1, | ||||||
|             max=r + 1, |             max=r + 1, | ||||||
| 
 | 
 | ||||||
|  | @ -791,11 +837,11 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
| 
 | 
 | ||||||
|         array_key: Optional[str] = None, |         array_key: Optional[str] = None, | ||||||
| 
 | 
 | ||||||
|     ) -> pg.GraphicsObject: |     ) -> (pg.GraphicsObject, str): | ||||||
|         """ |         ''' | ||||||
|         Draw OHLC datums to chart. |         Draw OHLC datums to chart. | ||||||
| 
 | 
 | ||||||
|         """ |         ''' | ||||||
|         graphics = BarItems( |         graphics = BarItems( | ||||||
|             self.plotItem, |             self.plotItem, | ||||||
|             pen_color=self.pen_color |             pen_color=self.pen_color | ||||||
|  | @ -810,17 +856,9 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
| 
 | 
 | ||||||
|         data_key = array_key or name |         data_key = array_key or name | ||||||
|         self._graphics[data_key] = graphics |         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') |         self._add_sticky(name, bg_color='davies') | ||||||
| 
 | 
 | ||||||
|         return graphics |         return graphics, data_key | ||||||
| 
 | 
 | ||||||
|     def draw_curve( |     def draw_curve( | ||||||
|         self, |         self, | ||||||
|  | @ -830,16 +868,18 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
| 
 | 
 | ||||||
|         array_key: Optional[str] = None, |         array_key: Optional[str] = None, | ||||||
|         overlay: bool = False, |         overlay: bool = False, | ||||||
|  |         separate_axes: bool = False, | ||||||
|         color: Optional[str] = None, |         color: Optional[str] = None, | ||||||
|         add_label: bool = True, |         add_label: bool = True, | ||||||
| 
 | 
 | ||||||
|         **pdi_kwargs, |         **pdi_kwargs, | ||||||
| 
 | 
 | ||||||
|     ) -> pg.PlotDataItem: |     ) -> (pg.PlotDataItem, str): | ||||||
|         """Draw a "curve" (line plot graphics) for the provided data in |         ''' | ||||||
|  |         Draw a "curve" (line plot graphics) for the provided data in | ||||||
|         the input array ``data``. |         the input array ``data``. | ||||||
| 
 | 
 | ||||||
|         """ |         ''' | ||||||
|         color = color or self.pen_color or 'default_light' |         color = color or self.pen_color or 'default_light' | ||||||
|         pdi_kwargs.update({ |         pdi_kwargs.update({ | ||||||
|             'color': color |             'color': color | ||||||
|  | @ -847,10 +887,6 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
| 
 | 
 | ||||||
|         data_key = array_key or name |         data_key = array_key or name | ||||||
| 
 | 
 | ||||||
|         # pg internals for reference. |  | ||||||
|         # curve = pg.PlotDataItem( |  | ||||||
|         # curve = pg.PlotCurveItem( |  | ||||||
| 
 |  | ||||||
|         # yah, we wrote our own B) |         # yah, we wrote our own B) | ||||||
|         curve = FastAppendCurve( |         curve = FastAppendCurve( | ||||||
|             y=data[data_key], |             y=data[data_key], | ||||||
|  | @ -881,34 +917,88 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
|         # and is disastrous for performance. |         # and is disastrous for performance. | ||||||
|         # curve.setCacheMode(QtWidgets.QGraphicsItem.ItemCoordinateCache) |         # curve.setCacheMode(QtWidgets.QGraphicsItem.ItemCoordinateCache) | ||||||
| 
 | 
 | ||||||
|         self.addItem(curve) |  | ||||||
| 
 |  | ||||||
|         # register curve graphics and backing array for name |         # register curve graphics and backing array for name | ||||||
|         self._graphics[name] = curve |         self._graphics[name] = curve | ||||||
|         self._arrays[data_key or name] = data |         self._arrays[data_key] = data | ||||||
| 
 | 
 | ||||||
|         if overlay: |         if overlay: | ||||||
|             anchor_at = ('bottom', 'left') |             # anchor_at = ('bottom', 'left') | ||||||
|             self._overlays[name] = None |             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: |         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 |             # TODO: something instead of stickies for overlays | ||||||
|             # (we need something that avoids clutter on x-axis). |             # (we need something that avoids clutter on x-axis). | ||||||
|             self._add_sticky(name, bg_color=color) |             self._add_sticky(name, bg_color=color) | ||||||
| 
 | 
 | ||||||
|         if self.linked.cursor: |         return curve, data_key | ||||||
|             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 |  | ||||||
| 
 | 
 | ||||||
|     # TODO: make this a ctx mngr |     # TODO: make this a ctx mngr | ||||||
|     def _add_sticky( |     def _add_sticky( | ||||||
|  | @ -949,7 +1039,7 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
|         '''Update the named internal graphics from ``array``. |         '''Update the named internal graphics from ``array``. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         self._arrays['ohlc'] = array |         self._arrays[self.name] = array | ||||||
|         graphics = self._graphics[graphics_name] |         graphics = self._graphics[graphics_name] | ||||||
|         graphics.update_from_array(array, **kwargs) |         graphics.update_from_array(array, **kwargs) | ||||||
|         return graphics |         return graphics | ||||||
|  | @ -970,7 +1060,7 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
|         data_key = array_key or graphics_name |         data_key = array_key or graphics_name | ||||||
| 
 | 
 | ||||||
|         if graphics_name not in self._overlays: |         if graphics_name not in self._overlays: | ||||||
|             self._arrays['ohlc'] = array |             self._arrays[self.name] = array | ||||||
|         else: |         else: | ||||||
|             self._arrays[data_key] = array |             self._arrays[data_key] = array | ||||||
| 
 | 
 | ||||||
|  | @ -992,102 +1082,6 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
| 
 | 
 | ||||||
|         return curve |         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: |     # def _label_h(self, yhigh: float, ylow: float) -> float: | ||||||
|     #     # compute contents label "height" in view terms |     #     # compute contents label "height" in view terms | ||||||
|     #     # to avoid having data "contents" overlap with them |     #     # to avoid having data "contents" overlap with them | ||||||
|  | @ -1140,3 +1134,59 @@ class ChartPlotWidget(pg.PlotWidget): | ||||||
|             return ohlc['index'][indexes][-1] |             return ohlc['index'][indexes][-1] | ||||||
|         else: |         else: | ||||||
|             return ohlc['index'][-1] |             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 | ||||||
|  |  | ||||||
|  | @ -276,7 +276,7 @@ class ContentsLabels: | ||||||
|     ) -> ContentsLabel: |     ) -> ContentsLabel: | ||||||
| 
 | 
 | ||||||
|         label = ContentsLabel( |         label = ContentsLabel( | ||||||
|             view=chart._vb, |             view=chart.view, | ||||||
|             anchor_at=anchor_at, |             anchor_at=anchor_at, | ||||||
|         ) |         ) | ||||||
|         self._labels.append( |         self._labels.append( | ||||||
|  | @ -418,13 +418,16 @@ class Cursor(pg.GraphicsObject): | ||||||
|         # keep x-axis right below main chart |         # keep x-axis right below main chart | ||||||
|         plot_index = -1 if _xaxis_at == 'bottom' else 0 |         plot_index = -1 if _xaxis_at == 'bottom' else 0 | ||||||
| 
 | 
 | ||||||
|         self.xaxis_label = XAxisLabel( |         # ONLY create an x-axis label for the cursor | ||||||
|             parent=self.plots[plot_index].getAxis('bottom'), |         # if this plot owns the 'bottom' axis. | ||||||
|             opacity=_ch_label_opac, |         if 'bottom' in plot.plotItem.axes: | ||||||
|             bg_color=self.label_color, |             self.xaxis_label = XAxisLabel( | ||||||
|         ) |                 parent=self.plots[plot_index].getAxis('bottom'), | ||||||
|         # place label off-screen during startup |                 opacity=_ch_label_opac, | ||||||
|         self.xaxis_label.setPos(self.plots[0].mapFromView(QPointF(0, 0))) |                 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( |     def add_curve_cursor( | ||||||
|         self, |         self, | ||||||
|  | @ -435,7 +438,7 @@ class Cursor(pg.GraphicsObject): | ||||||
|         # the current sample under the mouse |         # the current sample under the mouse | ||||||
|         cursor = LineDot( |         cursor = LineDot( | ||||||
|             curve, |             curve, | ||||||
|             index=plot._arrays['ohlc'][-1]['index'], |             index=plot._arrays[plot.name][-1]['index'], | ||||||
|             plot=plot |             plot=plot | ||||||
|         ) |         ) | ||||||
|         plot.addItem(cursor) |         plot.addItem(cursor) | ||||||
|  | @ -525,17 +528,18 @@ class Cursor(pg.GraphicsObject): | ||||||
|                 for cursor in opts.get('cursors', ()): |                 for cursor in opts.get('cursors', ()): | ||||||
|                     cursor.setIndex(ix) |                     cursor.setIndex(ix) | ||||||
| 
 | 
 | ||||||
|             # update the label on the bottom of the crosshair |                 # update the label on the bottom of the crosshair | ||||||
|             self.xaxis_label.update_label( |                 if 'bottom' in plot.plotItem.axes: | ||||||
|  |                     self.xaxis_label.update_label( | ||||||
| 
 | 
 | ||||||
|                 # XXX: requires: |                         # XXX: requires: | ||||||
|                 # https://github.com/pyqtgraph/pyqtgraph/pull/1418 |                         # https://github.com/pyqtgraph/pyqtgraph/pull/1418 | ||||||
|                 # otherwise gobbles tons of CPU.. |                         # otherwise gobbles tons of CPU.. | ||||||
| 
 | 
 | ||||||
|                 # map back to abs (label-local) coordinates |                         # map back to abs (label-local) coordinates | ||||||
|                 abs_pos=plot.mapFromView(QPointF(ix + line_offset, iy)), |                         abs_pos=plot.mapFromView(QPointF(ix + line_offset, iy)), | ||||||
|                 value=ix, |                         value=ix, | ||||||
|             ) |                     ) | ||||||
| 
 | 
 | ||||||
|         self._datum_xy = ix, iy |         self._datum_xy = ix, iy | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -117,7 +117,7 @@ def update_fsp_chart( | ||||||
|         array, |         array, | ||||||
|         array_key=array_key or graphics_name, |         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 |     # XXX: re: ``array_key``: fsp func names must be unique meaning we | ||||||
|     # can't have duplicates of the underlying data even if multiple |     # 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://arxiv.org/abs/cs/0610046 | ||||||
|     # https://github.com/lemire/pythonmaxmin |     # https://github.com/lemire/pythonmaxmin | ||||||
| 
 | 
 | ||||||
|     array = chart._arrays['ohlc'] |     array = chart._arrays[chart.name] | ||||||
|     ifirst = array[0]['index'] |     ifirst = array[0]['index'] | ||||||
| 
 | 
 | ||||||
|     last_bars_range = chart.bars_range() |     last_bars_range = chart.bars_range() | ||||||
|  | @ -212,6 +212,7 @@ async def update_chart_from_quotes( | ||||||
| 
 | 
 | ||||||
|     if vlm_chart: |     if vlm_chart: | ||||||
|         vlm_sticky = vlm_chart._ysticks['volume'] |         vlm_sticky = vlm_chart._ysticks['volume'] | ||||||
|  |         vlm_view = vlm_chart.view | ||||||
| 
 | 
 | ||||||
|     maxmin = partial(chart_maxmin, chart, vlm_chart) |     maxmin = partial(chart_maxmin, chart, vlm_chart) | ||||||
| 
 | 
 | ||||||
|  | @ -248,6 +249,7 @@ async def update_chart_from_quotes( | ||||||
|     tick_margin = 3 * tick_size |     tick_margin = 3 * tick_size | ||||||
| 
 | 
 | ||||||
|     chart.show() |     chart.show() | ||||||
|  |     view = chart.view | ||||||
|     last_quote = time.time() |     last_quote = time.time() | ||||||
| 
 | 
 | ||||||
|     async for quotes in stream: |     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 or | ||||||
|                     mx_vlm_in_view > last_mx_vlm |                     mx_vlm_in_view > last_mx_vlm | ||||||
|                 ): |                 ): | ||||||
|                     # print(f'mx vlm: {last_mx_vlm} -> {mx_vlm_in_view}') |                     print(f'mx vlm: {last_mx_vlm} -> {mx_vlm_in_view}') | ||||||
|                     vlm_chart._set_yrange(yrange=(0, mx_vlm_in_view * 1.375)) |                     vlm_view._set_yrange( | ||||||
|  |                         yrange=(0, mx_vlm_in_view * 1.375) | ||||||
|  |                     ) | ||||||
|                     last_mx_vlm = mx_vlm_in_view |                     last_mx_vlm = mx_vlm_in_view | ||||||
| 
 | 
 | ||||||
|             ticks_frame = quote.get('ticks', ()) |             ticks_frame = quote.get('ticks', ()) | ||||||
|  | @ -412,9 +416,12 @@ async def update_chart_from_quotes( | ||||||
|                     l1.bid_label.update_fields({'level': price, 'size': size}) |                     l1.bid_label.update_fields({'level': price, 'size': size}) | ||||||
| 
 | 
 | ||||||
|             # check for y-range re-size |             # check for y-range re-size | ||||||
|             if (mx > last_mx) or (mn < last_mn): |             if ( | ||||||
|                 # print(f'new y range: {(mn, mx)}') |                 (mx > last_mx) or (mn < last_mn) | ||||||
|                 chart._set_yrange( |                 and not chart._static_yrange == 'axis' | ||||||
|  |             ): | ||||||
|  |                 print(f'new y range: {(mn, mx)}') | ||||||
|  |                 view._set_yrange( | ||||||
|                     yrange=(mn, mx), |                     yrange=(mn, mx), | ||||||
|                     # TODO: we should probably scale |                     # TODO: we should probably scale | ||||||
|                     # the view margin based on the size |                     # the view margin based on the size | ||||||
|  | @ -436,6 +443,7 @@ async def update_chart_from_quotes( | ||||||
|                     name, |                     name, | ||||||
|                     array_key=name, |                     array_key=name, | ||||||
|                 ) |                 ) | ||||||
|  |                 subchart.cv._set_yrange() | ||||||
| 
 | 
 | ||||||
|                 # TODO: all overlays on all subplots.. |                 # TODO: all overlays on all subplots.. | ||||||
| 
 | 
 | ||||||
|  | @ -447,6 +455,7 @@ async def update_chart_from_quotes( | ||||||
|                     curve_name, |                     curve_name, | ||||||
|                     array_key=curve_name, |                     array_key=curve_name, | ||||||
|                 ) |                 ) | ||||||
|  |                 # chart._set_yrange() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def maybe_mk_fsp_shm( | 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, 70, orient_v='bottom') | ||||||
|             level_line(chart, 80, orient_v='top') |             level_line(chart, 80, orient_v='top') | ||||||
| 
 | 
 | ||||||
|         chart._set_yrange() |         chart.cv._set_yrange() | ||||||
|         done()  # status updates |         done()  # status updates | ||||||
| 
 | 
 | ||||||
|         profiler(f'fsp:{func_name} starting update loop') |         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 |             # size view to data once at outset | ||||||
|             chart._set_yrange() |             chart.cv._set_yrange() | ||||||
| 
 | 
 | ||||||
|             yield chart |             yield chart | ||||||
| 
 | 
 | ||||||
|  | @ -1070,7 +1079,7 @@ async def display_symbol_data( | ||||||
|                 ) |                 ) | ||||||
| 
 | 
 | ||||||
|         # size view to data once at outset |         # size view to data once at outset | ||||||
|         chart._set_yrange() |         chart.cv._set_yrange() | ||||||
| 
 | 
 | ||||||
|         # TODO: a data view api that makes this less shit |         # TODO: a data view api that makes this less shit | ||||||
|         chart._shm = ohlcv |         chart._shm = ohlcv | ||||||
|  |  | ||||||
|  | @ -342,7 +342,8 @@ class SelectRect(QtGui.QGraphicsRectItem): | ||||||
|         ixmn, ixmx = round(xmn), round(xmx) |         ixmn, ixmx = round(xmn), round(xmx) | ||||||
|         nbars = ixmx - ixmn + 1 |         nbars = ixmx - ixmn + 1 | ||||||
| 
 | 
 | ||||||
|         data = self._chart._arrays['ohlc'][ixmn:ixmx] |         chart = self._chart | ||||||
|  |         data = chart._arrays[chart.name][ixmn:ixmx] | ||||||
| 
 | 
 | ||||||
|         if len(data): |         if len(data): | ||||||
|             std = data['close'].std() |             std = data['close'].std() | ||||||
|  |  | ||||||
|  | @ -18,6 +18,7 @@ | ||||||
| Chart view box primitives | Chart view box primitives | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
|  | from __future__ import annotations | ||||||
| from contextlib import asynccontextmanager | from contextlib import asynccontextmanager | ||||||
| import time | import time | ||||||
| from typing import Optional, Callable | from typing import Optional, Callable | ||||||
|  | @ -155,6 +156,7 @@ async def handle_viewmode_kb_inputs( | ||||||
|             # View modes |             # View modes | ||||||
|             if key == Qt.Key_R: |             if key == Qt.Key_R: | ||||||
| 
 | 
 | ||||||
|  |                 # TODO: set this for all subplots | ||||||
|                 # edge triggered default view activation |                 # edge triggered default view activation | ||||||
|                 view.chart.default_view() |                 view.chart.default_view() | ||||||
| 
 | 
 | ||||||
|  | @ -332,12 +334,23 @@ class ChartView(ViewBox): | ||||||
|     ''' |     ''' | ||||||
|     mode_name: str = 'view' |     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__( |     def __init__( | ||||||
|         self, |         self, | ||||||
| 
 | 
 | ||||||
|         name: str, |         name: str, | ||||||
| 
 | 
 | ||||||
|         parent: pg.PlotItem = None, |         parent: pg.PlotItem = None, | ||||||
|  |         static_yrange: Optional[tuple[float, float]] = None, | ||||||
|         **kwargs, |         **kwargs, | ||||||
| 
 | 
 | ||||||
|     ): |     ): | ||||||
|  | @ -350,8 +363,15 @@ class ChartView(ViewBox): | ||||||
|             **kwargs |             **kwargs | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |         # for "known y-range style" | ||||||
|  |         self._static_yrange = static_yrange | ||||||
|  |         self._maxmin = None | ||||||
|  | 
 | ||||||
|         # disable vertical scrolling |         # disable vertical scrolling | ||||||
|         self.setMouseEnabled(x=True, y=False) |         self.setMouseEnabled( | ||||||
|  |             x=True, | ||||||
|  |             y=True, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         self.linkedsplits = None |         self.linkedsplits = None | ||||||
|         self._chart: 'ChartPlotWidget' = None  # noqa |         self._chart: 'ChartPlotWidget' = None  # noqa | ||||||
|  | @ -398,8 +418,15 @@ class ChartView(ViewBox): | ||||||
|     def chart(self, chart: 'ChartPlotWidget') -> None:  # type: ignore # noqa |     def chart(self, chart: 'ChartPlotWidget') -> None:  # type: ignore # noqa | ||||||
|         self._chart = chart |         self._chart = chart | ||||||
|         self.select_box.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. |         '''Override "center-point" location for scrolling. | ||||||
| 
 | 
 | ||||||
|         This is an override of the ``ViewBox`` method simply changing |         This is an override of the ``ViewBox`` method simply changing | ||||||
|  | @ -424,7 +451,7 @@ class ChartView(ViewBox): | ||||||
|             log.debug("Max zoom bruh...") |             log.debug("Max zoom bruh...") | ||||||
|             return |             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...") |             log.debug("Min zoom bruh...") | ||||||
|             return |             return | ||||||
| 
 | 
 | ||||||
|  | @ -432,67 +459,89 @@ class ChartView(ViewBox): | ||||||
|         s = 1.015 ** (ev.delta() * -1 / 20)  # self.state['wheelScaleFactor']) |         s = 1.015 ** (ev.delta() * -1 / 20)  # self.state['wheelScaleFactor']) | ||||||
|         s = [(None if m is False else s) for m in mask] |         s = [(None if m is False else s) for m in mask] | ||||||
| 
 | 
 | ||||||
|         # center = pg.Point( |         if ( | ||||||
|         #     fn.invertQTransform(self.childGroup.transform()).map(ev.pos()) |             # zoom happened on axis | ||||||
|         # ) |             axis == 1 | ||||||
| 
 | 
 | ||||||
|         # XXX: scroll "around" the right most element in the view |             # if already in axis zoom mode then keep it | ||||||
|         # which stays "pinned" in place. |             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( |         else: | ||||||
|         #     fn.invertQTransform( |  | ||||||
|         #         self.childGroup.transform() |  | ||||||
|         #     ).map(furthest_right_coord) |  | ||||||
|         # ) |  | ||||||
| 
 | 
 | ||||||
|         # This seems like the most "intuitive option, a hybrid of |             # center = pg.Point( | ||||||
|         # tws and tv styles |             #     fn.invertQTransform(self.childGroup.transform()).map(ev.pos()) | ||||||
|         last_bar = pg.Point(int(rbar)) + 1 |             # ) | ||||||
| 
 | 
 | ||||||
|         ryaxis = chart.getAxis('right') |             # XXX: scroll "around" the right most element in the view | ||||||
|         r_axis_x = ryaxis.pos().x() |             # which stays "pinned" in place. | ||||||
| 
 | 
 | ||||||
|         end_of_l1 = pg.Point( |             # furthest_right_coord = self.boundingRect().topRight() | ||||||
|             round( | 
 | ||||||
|                 chart._vb.mapToView( |             # yaxis = pg.Point( | ||||||
|                     pg.Point(r_axis_x - chart._max_l1_line_len) |             #     fn.invertQTransform( | ||||||
|                     # QPointF(chart._max_l1_line_len, 0) |             #         self.childGroup.transform() | ||||||
|                 ).x() |             #     ).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 |             self._resetTarget() | ||||||
| 
 |             self.scaleBy(s, focal) | ||||||
|         # focal = pg.Point((last_bar.x() + end_of_l1)/2) |             self.sigRangeChangedManually.emit(mask) | ||||||
| 
 |             ev.accept() | ||||||
|         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) |  | ||||||
| 
 | 
 | ||||||
|     def mouseDragEvent( |     def mouseDragEvent( | ||||||
|         self, |         self, | ||||||
|         ev, |         ev, | ||||||
|         axis: Optional[int] = None, |         axis: Optional[int] = None, | ||||||
|  |         relayed_from: ChartView = None, | ||||||
|  | 
 | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         #  if axis is specified, event will only affect that axis. |  | ||||||
|         ev.accept()  # we accept all buttons |  | ||||||
|         button = ev.button() |  | ||||||
| 
 | 
 | ||||||
|         pos = ev.pos() |         pos = ev.pos() | ||||||
|         lastPos = ev.lastPos() |         lastPos = ev.lastPos() | ||||||
|         dif = pos - lastPos |         dif = pos - lastPos | ||||||
|         dif = dif * -1 |         dif = dif * -1 | ||||||
| 
 | 
 | ||||||
|  |         # NOTE: if axis is specified, event will only affect that axis. | ||||||
|  |         button = ev.button() | ||||||
|  | 
 | ||||||
|         # Ignore axes if mouse is disabled |         # Ignore axes if mouse is disabled | ||||||
|         mouseEnabled = np.array(self.state['mouseEnabled'], dtype=np.float) |         mouseEnabled = np.array(self.state['mouseEnabled'], dtype=np.float) | ||||||
|         mask = mouseEnabled.copy() |         mask = mouseEnabled.copy() | ||||||
|  | @ -500,21 +549,28 @@ class ChartView(ViewBox): | ||||||
|             mask[1-axis] = 0.0 |             mask[1-axis] = 0.0 | ||||||
| 
 | 
 | ||||||
|         # Scale or translate based on mouse button |         # 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 |             # zoom y-axis ONLY when click-n-drag on it | ||||||
|             if axis == 1: |             # if axis == 1: | ||||||
|                 # set a static y range special value on chart widget to |             #     # set a static y range special value on chart widget to | ||||||
|                 # prevent sizing to data in view. |             #     # prevent sizing to data in view. | ||||||
|                 self.chart._static_yrange = 'axis' |             #     self.chart._static_yrange = 'axis' | ||||||
| 
 | 
 | ||||||
|                 scale_y = 1.3 ** (dif.y() * -1 / 20) |             #     scale_y = 1.3 ** (dif.y() * -1 / 20) | ||||||
|                 self.setLimits(yMin=None, yMax=None) |             #     self.setLimits(yMin=None, yMax=None) | ||||||
| 
 | 
 | ||||||
|                 # print(scale_y) |             #     # print(scale_y) | ||||||
|                 self.scaleBy((0, 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() |                 down_pos = ev.buttonDownPos() | ||||||
| 
 | 
 | ||||||
|  | @ -523,23 +579,36 @@ class ChartView(ViewBox): | ||||||
| 
 | 
 | ||||||
|                     self.select_box.mouse_drag_released(down_pos, pos) |                     self.select_box.mouse_drag_released(down_pos, pos) | ||||||
| 
 | 
 | ||||||
|                     # ax = QtCore.QRectF(down_pos, pos) |                     ax = QtCore.QRectF(down_pos, pos) | ||||||
|                     # ax = self.childGroup.mapRectFromParent(ax) |                     ax = self.childGroup.mapRectFromParent(ax) | ||||||
|                     # print(ax) |  | ||||||
| 
 | 
 | ||||||
|                     # this is the zoom transform cmd |                     # 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: |                 else: | ||||||
|  |                     print('drag finish?') | ||||||
|                     self.select_box.set_pos(down_pos, pos) |                     self.select_box.set_pos(down_pos, pos) | ||||||
| 
 | 
 | ||||||
|                     # update shape of scale box |                     # update shape of scale box | ||||||
|                     # self.updateScaleBox(ev.buttonDownPos(), ev.pos()) |                     # self.updateScaleBox(ev.buttonDownPos(), ev.pos()) | ||||||
|  |                     self.updateScaleBox( | ||||||
|  |                         down_pos, | ||||||
|  |                         ev.pos(), | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |             # PANNING MODE | ||||||
|             else: |             else: | ||||||
|                 # default bevavior: click to pan view |                 # XXX: WHY | ||||||
|  |                 ev.accept() | ||||||
|  | 
 | ||||||
|  |                 if axis == 1: | ||||||
|  |                     self.chart._static_yrange = 'axis' | ||||||
|  | 
 | ||||||
|                 tr = self.childGroup.transform() |                 tr = self.childGroup.transform() | ||||||
|                 tr = fn.invertQTransform(tr) |                 tr = fn.invertQTransform(tr) | ||||||
|                 tr = tr.map(dif*mask) - tr.map(Point(0, 0)) |                 tr = tr.map(dif*mask) - tr.map(Point(0, 0)) | ||||||
|  | @ -554,10 +623,9 @@ class ChartView(ViewBox): | ||||||
| 
 | 
 | ||||||
|                 self.sigRangeChangedManually.emit(self.state['mouseEnabled']) |                 self.sigRangeChangedManually.emit(self.state['mouseEnabled']) | ||||||
| 
 | 
 | ||||||
|  |         # WEIRD "RIGHT-CLICK CENTER ZOOM" MODE | ||||||
|         elif button & QtCore.Qt.RightButton: |         elif button & QtCore.Qt.RightButton: | ||||||
| 
 | 
 | ||||||
|             # right click zoom to center behaviour |  | ||||||
| 
 |  | ||||||
|             if self.state['aspectLocked'] is not False: |             if self.state['aspectLocked'] is not False: | ||||||
|                 mask[0] = 0 |                 mask[0] = 0 | ||||||
| 
 | 
 | ||||||
|  | @ -577,6 +645,9 @@ class ChartView(ViewBox): | ||||||
|             self.scaleBy(x=x, y=y, center=center) |             self.scaleBy(x=x, y=y, center=center) | ||||||
|             self.sigRangeChangedManually.emit(self.state['mouseEnabled']) |             self.sigRangeChangedManually.emit(self.state['mouseEnabled']) | ||||||
| 
 | 
 | ||||||
|  |             # XXX: WHY | ||||||
|  |             ev.accept() | ||||||
|  | 
 | ||||||
|     # def mouseClickEvent(self, event: QtCore.QEvent) -> None: |     # def mouseClickEvent(self, event: QtCore.QEvent) -> None: | ||||||
|     #      '''This routine is rerouted to an async handler. |     #      '''This routine is rerouted to an async handler. | ||||||
|     #      ''' |     #      ''' | ||||||
|  | @ -591,3 +662,107 @@ class ChartView(ViewBox): | ||||||
|         '''This routine is rerouted to an async handler. |         '''This routine is rerouted to an async handler. | ||||||
|         ''' |         ''' | ||||||
|         pass |         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' | ||||||
|  |  | ||||||
|  | @ -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 <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | 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 | ||||||
|  |     ``<axis_name>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 | ||||||
		Loading…
	
		Reference in New Issue