commit
05b8e3a199
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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