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