piker/piker/ui/_interaction.py

985 lines
29 KiB
Python
Raw Normal View History

2020-11-06 17:23:14 +00:00
# piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for pikers)
2020-11-06 17:23:14 +00:00
# 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/>.
'''
Chart view box primitives
'''
from __future__ import annotations
from contextlib import (
asynccontextmanager,
ExitStack,
)
import time
from typing import (
Callable,
TYPE_CHECKING,
)
2020-12-30 17:55:02 +00:00
2020-08-15 02:17:57 +00:00
import pyqtgraph as pg
# from pyqtgraph.GraphicsScene import mouseEvents
from PyQt5.QtWidgets import QGraphicsSceneMouseEvent as gs_mouse
2021-06-15 23:02:46 +00:00
from PyQt5.QtCore import Qt, QEvent
from pyqtgraph import ViewBox, Point, QtCore
2020-08-15 02:17:57 +00:00
from pyqtgraph import functions as fn
import numpy as np
import trio
2020-08-15 02:17:57 +00:00
from ..log import get_logger
from .._profile import Profiler
2022-04-04 21:29:33 +00:00
from .._profile import pg_profile_enabled, ms_slower_then
from .view_mode import overlay_viewlists
# from ._style import _min_points_to_show
2021-06-15 23:02:46 +00:00
from ._editors import SelectRect
from . import _event
2020-08-15 02:17:57 +00:00
if TYPE_CHECKING:
from ._chart import ChartPlotWidget
from ._dataviz import Viz
2020-08-15 02:17:57 +00:00
log = get_logger(__name__)
NUMBER_LINE = {
Qt.Key_1,
Qt.Key_2,
Qt.Key_3,
Qt.Key_4,
Qt.Key_5,
Qt.Key_6,
Qt.Key_7,
Qt.Key_8,
Qt.Key_9,
Qt.Key_0,
}
ORDER_MODE = {
Qt.Key_A,
Qt.Key_F,
Qt.Key_D,
}
2020-08-15 02:17:57 +00:00
async def handle_viewmode_kb_inputs(
view: ChartView,
recv_chan: trio.abc.ReceiveChannel,
) -> None:
order_mode = view.order_mode
# track edge triggered keys
# (https://en.wikipedia.org/wiki/Interrupt#Triggering_methods)
pressed: set[str] = set()
last = time.time()
action: str
on_next_release: Callable | None = None
# for quick key sequence-combo pattern matching
# we have a min_tap period and these should not
# ever be auto-repeats since we filter those at the
# event filter level prior to the above mem chan.
min_tap = 1/6
fast_key_seq: list[str] = []
fast_taps: dict[str, Callable] = {
'cc': order_mode.cancel_all_orders,
}
async for kbmsg in recv_chan:
event, etype, key, mods, text = kbmsg.to_tuple()
log.debug(f'key: {key}, mods: {mods}, text: {text}')
now = time.time()
period = now - last
# reset mods
ctrl: bool = False
shift: bool = False
# press branch
if etype in {QEvent.KeyPress}:
pressed.add(key)
if (
# clear any old values not part of a "fast" tap sequence:
# presumes the period since last tap is longer then our
# min_tap period
fast_key_seq and period >= min_tap or
# don't support more then 2 key sequences for now
len(fast_key_seq) > 2
):
fast_key_seq.clear()
# capture key to fast tap sequence if we either
# have no previous keys or we do and the min_tap period is
# met
if (
not fast_key_seq or
period <= min_tap and fast_key_seq
):
fast_key_seq.append(text)
log.debug(f'fast keys seqs {fast_key_seq}')
# mods run through
if mods == Qt.ShiftModifier:
shift = True
if mods == Qt.ControlModifier:
ctrl = True
# UI REPL-shell
if (
ctrl and key in {
Qt.Key_U,
}
):
import tractor
Rework overlay pin technique: "align to first" As part of solving a final bullet-issue in #455, which is specifically a case: - with N > 2 curves, one of which is the "major" dispersion curve" and the others are "minors", - we can run into a scenario where some minor curve which gets pinned to the major (due to the original "pinning technique" -> "align to major") at some `P(t)` which is *not* the major's minimum / maximum due to the minor having a smaller/shorter support and thus, - requires that in order to show then max/min on the minor curve we have to expand the range of the major curve as well but, - that also means any previously scaled (to the major) minor curves need to be adjusted as well or they'll not be pinned to the major the same way! I originally was trying to avoid doing the recursive iteration back through all previously scaled minor curves and instead decided to try implementing the "per side" curve dispersion detection (as was originally attempted when first starting this work). The idea is to decide which curve's up or down "swing in % returns" would determine the global y-range *on that side*. Turns out I stumbled on the "align to first" technique in the process: "for each overlay curve we align its earliest sample (in time) to the same level of the earliest such sample for whatever is deemed the major (directionally disperse) curve in view". I decided (with help) that this "pin to first" approach/style is equally as useful and maybe often more so when wanting to view support-disjoint time series: - instead of compressing the y-range on "longer series which have lesser sigma" to make whatever "shorter but larger-sigma series" pin to it at an intersect time step, this instead will expand the price ranges based on the earliest time step in each series. - the output global-returns-overlay-range for any N-set of series is equal to the same in the previous "pin to intersect time" technique. - the only time this technique seems less useful is for overlaying market feeds which have the same destination asset but different source assets (eg. btceur and btcusd on the same chart since if one of the series is shorter it will always be aligned to the earliest datum on the longer instead of more naturally to the intersect sample level as was in the previous approach). As such I'm going to keep this technique as discovered and will later add back optional support for the "align to intersect" approach from previous (which will again require detecting the highest dispersion curve direction-agnostic) and pin all minors to the price level at which they start on the major. Further details of the implementation rework in `.interact_graphics_cycle()` include: - add `intersect_from_longer()` to detect and deliver a common datum from 2 series which are different in length: the first time-index sample in the longer. - Rewrite the drafted `OverlayT` to only compute (inversed log-returns) transforms for a single direction and use 2 instances, one for each direction inside the `Viz`-overlay iteration loop. - do all dispersion-per-side major curve detection in the first pass of all `Viz`s on a plot, instead updating the `OverlayT` instances for each side and compensating for any length mismatch and rescale-to-minor cases in each loop cycle.
2023-02-16 20:23:56 +00:00
god = order_mode.godw # noqa
feed = order_mode.feed # noqa
chart = order_mode.chart # noqa
viz = chart.main_viz # noqa
Rework overlay pin technique: "align to first" As part of solving a final bullet-issue in #455, which is specifically a case: - with N > 2 curves, one of which is the "major" dispersion curve" and the others are "minors", - we can run into a scenario where some minor curve which gets pinned to the major (due to the original "pinning technique" -> "align to major") at some `P(t)` which is *not* the major's minimum / maximum due to the minor having a smaller/shorter support and thus, - requires that in order to show then max/min on the minor curve we have to expand the range of the major curve as well but, - that also means any previously scaled (to the major) minor curves need to be adjusted as well or they'll not be pinned to the major the same way! I originally was trying to avoid doing the recursive iteration back through all previously scaled minor curves and instead decided to try implementing the "per side" curve dispersion detection (as was originally attempted when first starting this work). The idea is to decide which curve's up or down "swing in % returns" would determine the global y-range *on that side*. Turns out I stumbled on the "align to first" technique in the process: "for each overlay curve we align its earliest sample (in time) to the same level of the earliest such sample for whatever is deemed the major (directionally disperse) curve in view". I decided (with help) that this "pin to first" approach/style is equally as useful and maybe often more so when wanting to view support-disjoint time series: - instead of compressing the y-range on "longer series which have lesser sigma" to make whatever "shorter but larger-sigma series" pin to it at an intersect time step, this instead will expand the price ranges based on the earliest time step in each series. - the output global-returns-overlay-range for any N-set of series is equal to the same in the previous "pin to intersect time" technique. - the only time this technique seems less useful is for overlaying market feeds which have the same destination asset but different source assets (eg. btceur and btcusd on the same chart since if one of the series is shorter it will always be aligned to the earliest datum on the longer instead of more naturally to the intersect sample level as was in the previous approach). As such I'm going to keep this technique as discovered and will later add back optional support for the "align to intersect" approach from previous (which will again require detecting the highest dispersion curve direction-agnostic) and pin all minors to the price level at which they start on the major. Further details of the implementation rework in `.interact_graphics_cycle()` include: - add `intersect_from_longer()` to detect and deliver a common datum from 2 series which are different in length: the first time-index sample in the longer. - Rewrite the drafted `OverlayT` to only compute (inversed log-returns) transforms for a single direction and use 2 instances, one for each direction inside the `Viz`-overlay iteration loop. - do all dispersion-per-side major curve detection in the first pass of all `Viz`s on a plot, instead updating the `OverlayT` instances for each side and compensating for any length mismatch and rescale-to-minor cases in each loop cycle.
2023-02-16 20:23:56 +00:00
vlm_chart = chart.linked.subplots['volume'] # noqa
vlm_viz = vlm_chart.main_viz # noqa
Rework overlay pin technique: "align to first" As part of solving a final bullet-issue in #455, which is specifically a case: - with N > 2 curves, one of which is the "major" dispersion curve" and the others are "minors", - we can run into a scenario where some minor curve which gets pinned to the major (due to the original "pinning technique" -> "align to major") at some `P(t)` which is *not* the major's minimum / maximum due to the minor having a smaller/shorter support and thus, - requires that in order to show then max/min on the minor curve we have to expand the range of the major curve as well but, - that also means any previously scaled (to the major) minor curves need to be adjusted as well or they'll not be pinned to the major the same way! I originally was trying to avoid doing the recursive iteration back through all previously scaled minor curves and instead decided to try implementing the "per side" curve dispersion detection (as was originally attempted when first starting this work). The idea is to decide which curve's up or down "swing in % returns" would determine the global y-range *on that side*. Turns out I stumbled on the "align to first" technique in the process: "for each overlay curve we align its earliest sample (in time) to the same level of the earliest such sample for whatever is deemed the major (directionally disperse) curve in view". I decided (with help) that this "pin to first" approach/style is equally as useful and maybe often more so when wanting to view support-disjoint time series: - instead of compressing the y-range on "longer series which have lesser sigma" to make whatever "shorter but larger-sigma series" pin to it at an intersect time step, this instead will expand the price ranges based on the earliest time step in each series. - the output global-returns-overlay-range for any N-set of series is equal to the same in the previous "pin to intersect time" technique. - the only time this technique seems less useful is for overlaying market feeds which have the same destination asset but different source assets (eg. btceur and btcusd on the same chart since if one of the series is shorter it will always be aligned to the earliest datum on the longer instead of more naturally to the intersect sample level as was in the previous approach). As such I'm going to keep this technique as discovered and will later add back optional support for the "align to intersect" approach from previous (which will again require detecting the highest dispersion curve direction-agnostic) and pin all minors to the price level at which they start on the major. Further details of the implementation rework in `.interact_graphics_cycle()` include: - add `intersect_from_longer()` to detect and deliver a common datum from 2 series which are different in length: the first time-index sample in the longer. - Rewrite the drafted `OverlayT` to only compute (inversed log-returns) transforms for a single direction and use 2 instances, one for each direction inside the `Viz`-overlay iteration loop. - do all dispersion-per-side major curve detection in the first pass of all `Viz`s on a plot, instead updating the `OverlayT` instances for each side and compensating for any length mismatch and rescale-to-minor cases in each loop cycle.
2023-02-16 20:23:56 +00:00
dvlm_pi = vlm_chart._vizs['dolla_vlm'].plot # noqa
await tractor.breakpoint()
view.interact_graphics_cycle()
# SEARCH MODE #
# ctlr-<space>/<l> for "lookup", "search" -> open search tree
if (
ctrl and key in {
Qt.Key_L,
Qt.Key_Space,
}
):
2022-09-07 21:50:10 +00:00
godw = view._chart.linked.godwidget
godw.hist_linked.resize_sidepanes(from_linked=godw.rt_linked)
2022-09-07 21:50:10 +00:00
godw.search.focus()
# esc and ctrl-c
if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C):
# ctrl-c as cancel
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
view.select_box.clear()
2022-09-06 12:36:28 +00:00
view.linked.focus()
# cancel order or clear graphics
if key == Qt.Key_C or key == Qt.Key_Delete:
order_mode.cancel_orders_under_cursor()
# View modes
if key == Qt.Key_R:
# NOTE: seems that if we don't yield a Qt render
# cycle then the m4 downsampled curves will show here
# without another reset..
view._viz.default_view()
view.interact_graphics_cycle()
await trio.sleep(0)
view.interact_graphics_cycle()
if len(fast_key_seq) > 1:
# begin matches against sequences
func: Callable = fast_taps.get(''.join(fast_key_seq))
if func:
func()
fast_key_seq.clear()
# release branch
elif etype in {QEvent.KeyRelease}:
if on_next_release:
on_next_release()
on_next_release = None
if key in pressed:
pressed.remove(key)
# QUERY/QUOTE MODE
# ----------------
if {Qt.Key_Q}.intersection(pressed):
2022-09-06 12:36:28 +00:00
view.linked.cursor.in_query_mode = True
else:
2022-09-06 12:36:28 +00:00
view.linked.cursor.in_query_mode = False
# SELECTION MODE
# --------------
if shift:
if view.state['mouseMode'] == ViewBox.PanMode:
view.setMouseMode(ViewBox.RectMode)
else:
view.setMouseMode(ViewBox.PanMode)
2021-07-26 15:33:14 +00:00
# Toggle position config pane
if (
ctrl and key in {
Qt.Key_P,
}
):
pp_pane = order_mode.current_pp.pane
if pp_pane.isHidden():
pp_pane.show()
2021-07-26 15:33:14 +00:00
else:
pp_pane.hide()
# ORDER MODE
# ----------
# live vs. dark trigger + an action {buy, sell, alert}
order_keys_pressed = ORDER_MODE.intersection(pressed)
if order_keys_pressed:
2021-07-22 00:00:57 +00:00
2022-09-19 20:05:18 +00:00
# TODO: it seems like maybe the composition should be
# reversed here? Like, maybe we should have the nav have
# access to the pos state and then make encapsulated logic
# that shows the right stuff on screen instead or order mode
# and position-related abstractions doing this?
# show the pp size label only if there is
# a non-zero pos existing
tracker = order_mode.current_pp
if tracker.live_pp.size:
tracker.nav.show()
2021-07-26 15:33:14 +00:00
# TODO: show pp config mini-params in status bar widget
# mode.pp_config.show()
2021-07-22 00:00:57 +00:00
trigger_type: str = 'dark'
if (
# 's' for "submit" to activate "live" order
Qt.Key_S in pressed or
ctrl
):
trigger_type: str = 'live'
# order mode trigger "actions"
if Qt.Key_D in pressed: # for "damp eet"
action = 'sell'
elif Qt.Key_F in pressed: # for "fillz eet"
action = 'buy'
elif Qt.Key_A in pressed:
action = 'alert'
trigger_type = 'live'
order_mode.active = True
# XXX: order matters here for line style!
order_mode._trigger_type = trigger_type
order_mode.stage_order(
action,
trigger_type=trigger_type,
)
prefix = trigger_type + '-' if action != 'alert' else ''
2021-07-24 20:07:04 +00:00
view._chart.window().set_mode_name(f'{prefix}{action}')
elif (
(
Qt.Key_S in pressed or
order_keys_pressed or
Qt.Key_O in pressed
2022-09-19 20:05:18 +00:00
)
and key in NUMBER_LINE
):
# hot key to set order slots size.
# change edit field to current number line value,
# update the pp allocator bar, unhighlight the
# field when ctrl is released.
num = int(text)
2021-08-23 18:48:20 +00:00
pp_pane = order_mode.pane
pp_pane.on_ui_settings_change('slots', num)
2021-08-23 18:48:20 +00:00
edit = pp_pane.form.fields['slots']
edit.selectAll()
# un-highlight on ctrl release
on_next_release = edit.deselect
2021-09-13 23:08:30 +00:00
pp_pane.update_status_ui(pp_pane.order_mode.current_pp)
else: # none active
2021-07-22 00:00:57 +00:00
# hide pp label
order_mode.current_pp.nav.hide_info()
2021-07-22 00:00:57 +00:00
# if none are pressed, remove "staged" level
# line under cursor position
order_mode.lines.unstage_line()
if view.hasFocus():
# update mode label
2021-07-24 20:07:04 +00:00
view._chart.window().set_mode_name('view')
order_mode.active = False
last = time.time()
async def handle_viewmode_mouse(
view: ChartView,
recv_chan: trio.abc.ReceiveChannel,
) -> None:
async for msg in recv_chan:
button = msg.button
# XXX: ugggh ``pyqtgraph`` has its own mouse events..
# so we can't overried this easily.
# it's going to take probably some decent
# reworking of the mouseClickEvent() handler.
# if button == QtCore.Qt.RightButton and view.menuEnabled():
# event = mouseEvents.MouseClickEvent(msg.event)
# # event.accept()
# view.raiseContextMenu(event)
if (
view.order_mode.active and
button == QtCore.Qt.LeftButton
):
# when in order mode, submit execution
# msg.event.accept()
view.order_mode.submit_order()
class ChartView(ViewBox):
'''
Price chart view box with interaction behaviors you'd expect from
2020-08-15 02:17:57 +00:00
any interactive platform:
- zoom on mouse scroll that auto fits y-axis
- vertical scrolling on y-axis
- zoom on x to most recent in view datum
- zoom on right-click-n-drag to cursor position
'''
2021-07-24 20:07:04 +00:00
mode_name: str = 'view'
2021-05-30 12:45:55 +00:00
2020-08-15 02:17:57 +00:00
def __init__(
self,
2021-07-22 00:00:57 +00:00
name: str,
2021-07-22 00:00:57 +00:00
parent: pg.PlotItem = None,
static_yrange: tuple[float, float] | None = None,
2020-08-15 02:17:57 +00:00
**kwargs,
2020-08-15 02:17:57 +00:00
):
2021-09-18 21:09:30 +00:00
super().__init__(
parent=parent,
name=name,
2021-09-18 21:09:30 +00:00
# TODO: look into the default view padding
# support that might replace somem of our
# ``ChartPlotWidget._set_yrange()`
# defaultPadding=0.,
**kwargs
)
# for "known y-range style"
self._static_yrange = static_yrange
2020-08-15 02:17:57 +00:00
# disable vertical scrolling
self.setMouseEnabled(
x=True,
y=True,
)
2022-09-06 12:36:28 +00:00
self.linked = None
self._chart: ChartPlotWidget | None = None # noqa
# add our selection box annotator
self.select_box = SelectRect(self)
self.addItem(self.select_box, ignoreBounds=True)
self.mode = None
self.order_mode: bool = False
self.setFocusPolicy(QtCore.Qt.StrongFocus)
self._in_interact: trio.Event | None = None
self._interact_stack: ExitStack = ExitStack()
# TODO: probably just assign this whenever a new `PlotItem` is
# allocated since they're 1to1 with views..
self._viz: Viz | None = None
self._yrange: tuple[float, float] | None = None
def start_ic(
self,
) -> None:
2022-04-07 18:15:18 +00:00
'''
Signal the beginning of a click-drag interaction
to any interested task waiters.
'''
if self._in_interact is None:
chart = self.chart
try:
self._in_interact = trio.Event()
chart.pause_all_feeds()
self._interact_stack.enter_context(
chart.reset_graphics_caches()
)
except RuntimeError:
pass
def signal_ic(
self,
*args,
2022-04-07 18:15:18 +00:00
) -> None:
2022-04-07 18:15:18 +00:00
'''
Signal the end of a click-drag interaction
to any waiters.
2022-04-07 18:15:18 +00:00
'''
if self._in_interact:
try:
self._interact_stack.close()
self.chart.resume_all_feeds()
self._in_interact.set()
self._in_interact = None
except RuntimeError:
pass
@asynccontextmanager
async def open_async_input_handler(
self,
2021-07-22 00:00:57 +00:00
) -> ChartView:
async with (
_event.open_handlers(
[self],
event_types={
QEvent.KeyPress,
QEvent.KeyRelease,
},
async_handler=handle_viewmode_kb_inputs,
),
_event.open_handlers(
[self],
event_types={
gs_mouse.GraphicsSceneMousePress,
},
async_handler=handle_viewmode_mouse,
),
):
yield self
@property
def chart(self) -> ChartPlotWidget: # type: ignore # noqa
return self._chart
@chart.setter
def chart(self, chart: ChartPlotWidget) -> None: # type: ignore # noqa
self._chart = chart
self.select_box.chart = chart
def wheelEvent(
self,
ev,
axis=None,
):
'''
Override "center-point" location for scrolling.
2020-08-15 02:17:57 +00:00
This is an override of the ``ViewBox`` method simply changing
the center of the zoom to be the y-axis.
TODO: PR a method into ``pyqtgraph`` to make this configurable
'''
Re-work chart-overlay event broadcasting Drop all attempts at rewiring `ViewBox` signals, monkey-patching relayee handlers, and generally modifying event source public attributes. Instead take a much simpler approach where the event source graphics object simply has it's handler dynamically overridden by a broadcaster function which relays to all consumers using a Python loop. The benefits of this much simplified approach include: - avoiding the tedious and often complex (re)connection of signals between the source plot and the overlayed consumers. - requiring zero modification of the public interface of any of the publisher or consumer `ViewBox`s, no decoration, extra signal definitions (eg. previous `mouseDragEventRelay` or the like). - only a single dynamic method override on the event source graphics object (`ViewBox`) which does the broadcasting work and requires no modification to handler implementations. Detailed `.ui._overlay` changes: - drop `mk_relay_signal()`, `enable_relays()` which removes signal/slot hacking methodology. - drop unused `ComposedGridLayout.grid` and `.reverse`, change some method names: `.insert()` -> `.insert_plotitem()`, `append()` -> `.append_plotitem()`. - in `PlotOverlay`, again drop all signal/slot rewiring in `.add_plotitem()` and instead add our new closure based python-loop in `broadcast()` routine which is used to override the event-source object's handler. - comment out all the auxiliary/want-to-have event source selection methods for now.
2022-11-04 20:28:45 +00:00
linked = self.linked
if (
not linked
):
return
2020-08-15 02:17:57 +00:00
if axis in (0, 1):
mask = [False, False]
mask[axis] = self.state['mouseEnabled'][axis]
else:
mask = self.state['mouseEnabled'][:]
2022-09-06 12:36:28 +00:00
chart = self.linked.chart
2021-03-17 12:25:58 +00:00
2020-08-15 02:17:57 +00:00
# don't zoom more then the min points setting
viz = chart.get_viz(chart.name)
_, vl, lbar, rbar, vr, r = viz.datums_range()
2020-08-15 02:17:57 +00:00
# TODO: max/min zoom limits incorporating time step size.
# rl = vr - vl
# if ev.delta() > 0 and rl <= _min_points_to_show:
# log.warning("Max zoom bruh...")
# return
# if (
# ev.delta() < 0
# and rl >= len(chart._vizs[chart.name].shm.array) + 666
# ):
# log.warning("Min zoom bruh...")
# return
2020-08-15 02:17:57 +00:00
# actual scaling factor
s = 1.016 ** (ev.delta() * -1 / 20) # self.state['wheelScaleFactor'])
2020-08-15 02:17:57 +00:00
s = [(None if m is False else s) for m in mask]
if (
# zoom happened on axis
axis == 1
2020-08-15 02:17:57 +00:00
# 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)
# print(scale_y)
# pos = ev.pos()
# lastPos = ev.lastPos()
# dif = pos - lastPos
# dif = dif * -1
2022-02-08 20:57:32 +00:00
center = Point(
fn.invertQTransform(
self.childGroup.transform()
).map(ev.pos())
)
# scale_y = 1.3 ** (center.y() * -1 / 20)
self.scaleBy(s, center)
# zoom in view-box area
else:
# use right-most point of current curve graphic
xl = viz.graphics.x_last()
focal = min(
xl,
r,
)
2020-08-15 02:17:57 +00:00
self._resetTarget()
# NOTE: scroll "around" the right most datum-element in view
# gives the feeling of staying "pinned" in place.
self.scaleBy(s, focal)
# XXX: the order of the next 2 lines i'm pretty sure
# matters, we want the resize to trigger before the graphics
# update, but i gotta feelin that because this one is signal
# based (and thus not necessarily sync invoked right away)
# that calling the resize method manually might work better.
# self.sigRangeChangedManually.emit(mask)
# XXX: without this is seems as though sometimes
# when zooming in from far out (and maybe vice versa?)
# the signal isn't being fired enough since if you pan
# just after you'll see further downsampling code run
# (pretty noticeable on the OHLC ds curve) but with this
# that never seems to happen? Only question is how much this
# "double work" is causing latency when these missing event
# fires don't happen?
self.interact_graphics_cycle()
self.interact_graphics_cycle()
ev.accept()
2020-12-30 17:55:02 +00:00
def mouseDragEvent(
self,
ev,
axis: int | None = None,
2020-12-30 17:55:02 +00:00
) -> None:
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()
if axis is not None:
mask[1-axis] = 0.0
# Scale or translate based on mouse button
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'
2020-12-30 17:55:02 +00:00
# scale_y = 1.3 ** (dif.y() * -1 / 20)
# self.setLimits(yMin=None, yMax=None)
2020-12-30 17:55:02 +00:00
# # print(scale_y)
# self.scaleBy((0, scale_y))
2020-12-30 17:55:02 +00:00
# SELECTION MODE
if (
self.state['mouseMode'] == ViewBox.RectMode
and axis is None
):
# XXX: WHY
ev.accept()
down_pos = ev.buttonDownPos()
# This is the final position in the drag
if ev.isFinish():
self.select_box.mouse_drag_released(down_pos, pos)
ax = QtCore.QRectF(down_pos, pos)
ax = self.childGroup.mapRectFromParent(ax)
# this is the zoom transform cmd
self.showAxRect(ax)
# axis history tracking
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:
try:
self.start_ic()
except RuntimeError:
pass
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))
x = tr.x() if mask[0] == 1 else None
y = tr.y() if mask[1] == 1 else None
self._resetTarget()
2021-01-01 22:49:23 +00:00
if x is not None or y is not None:
self.translateBy(x=x, y=y)
2021-01-01 22:49:23 +00:00
# self.sigRangeChangedManually.emit(mask)
# self.state['mouseEnabled']
# )
self.interact_graphics_cycle()
if ev.isFinish():
self.signal_ic()
# self._in_interact.set()
# self._in_interact = None
# self.chart.resume_all_feeds()
# # XXX: WHY
# ev.accept()
# WEIRD "RIGHT-CLICK CENTER ZOOM" MODE
2021-01-01 22:49:23 +00:00
elif button & QtCore.Qt.RightButton:
if self.state['aspectLocked'] is not False:
mask[0] = 0
dif = ev.screenPos() - ev.lastScreenPos()
dif = np.array([dif.x(), dif.y()])
dif[0] *= -1
s = ((mask * 0.02) + 1) ** dif
tr = self.childGroup.transform()
tr = fn.invertQTransform(tr)
x = s[0] if mouseEnabled[0] == 1 else None
y = s[1] if mouseEnabled[1] == 1 else None
center = Point(tr.map(ev.buttonDownPos(QtCore.Qt.RightButton)))
self._resetTarget()
self.scaleBy(x=x, y=y, center=center)
# self.sigRangeChangedManually.emit(self.state['mouseEnabled'])
self.interact_graphics_cycle()
# XXX: WHY
ev.accept()
# def mouseClickEvent(self, event: QtCore.QEvent) -> None:
# '''This routine is rerouted to an async handler.
# '''
# pass
def keyReleaseEvent(self, event: QtCore.QEvent) -> None:
'''This routine is rerouted to an async handler.
'''
pass
def keyPressEvent(self, event: QtCore.QEvent) -> None:
'''This routine is rerouted to an async handler.
'''
pass
def _set_yrange(
self,
*,
yrange: tuple[float, float] | None = None,
viz: Viz | None = None,
# NOTE: this value pairs (more or less) with L1 label text
# height offset from from the bid/ask lines.
range_margin: float | None = 0.06,
bars_range: tuple[int, int, int, int] | None = None,
# flag to prevent triggering sibling charts from the same linked
# set from recursion errors.
autoscale_linked_plots: bool = False,
name: str | None = None,
) -> 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.
'''
name = self.name
# print(f'YRANGE ON {name} -> yrange{yrange}')
profiler = Profiler(
msg=f'`ChartView._set_yrange()`: `{name}`',
disabled=not pg_profile_enabled(),
ms_threshold=ms_slower_then,
delayed=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':
self.setLimits(yMin=None, yMax=None)
return
# 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
# XXX: only compute the mxmn range
# if none is provided as input!
if not yrange:
if not viz:
breakpoint()
out = viz.maxmin()
if out is None:
log.warning(f'No yrange provided for {name}!?')
return
(
ixrng,
_,
yrange
) = out
profiler(f'`{self.name}:Viz.maxmin()` -> {ixrng}=>{yrange}')
if yrange is None:
log.warning(f'No yrange provided for {name}!?')
return
ylow, yhigh = yrange
# always stash last range for diffing by
# incremental update calculations BEFORE adding
# margin.
self._yrange = ylow, yhigh
# view margins: stay within a % of the "true range"
if range_margin is not None:
diff = yhigh - ylow
ylow = max(
ylow - (diff * range_margin),
0,
)
yhigh = min(
yhigh + (diff * range_margin),
yhigh * (1 + range_margin),
)
# print(
# f'set limits {self.name}:\n'
# f'ylow: {ylow}\n'
# f'yhigh: {yhigh}\n'
# )
self.setYRange(
ylow,
yhigh,
padding=0,
)
self.setLimits(
yMin=ylow,
yMax=yhigh,
)
self.update()
# LOL: yet anothercucking pg buggg..
# can't use `msg=f'setYRange({ylow}, {yhigh}')`
profiler.finish()
def enable_auto_yrange(
self,
viz: Viz,
src_vb: ChartView | None = None,
) -> None:
'''
Re-work chart-overlay event broadcasting Drop all attempts at rewiring `ViewBox` signals, monkey-patching relayee handlers, and generally modifying event source public attributes. Instead take a much simpler approach where the event source graphics object simply has it's handler dynamically overridden by a broadcaster function which relays to all consumers using a Python loop. The benefits of this much simplified approach include: - avoiding the tedious and often complex (re)connection of signals between the source plot and the overlayed consumers. - requiring zero modification of the public interface of any of the publisher or consumer `ViewBox`s, no decoration, extra signal definitions (eg. previous `mouseDragEventRelay` or the like). - only a single dynamic method override on the event source graphics object (`ViewBox`) which does the broadcasting work and requires no modification to handler implementations. Detailed `.ui._overlay` changes: - drop `mk_relay_signal()`, `enable_relays()` which removes signal/slot hacking methodology. - drop unused `ComposedGridLayout.grid` and `.reverse`, change some method names: `.insert()` -> `.insert_plotitem()`, `append()` -> `.append_plotitem()`. - in `PlotOverlay`, again drop all signal/slot rewiring in `.add_plotitem()` and instead add our new closure based python-loop in `broadcast()` routine which is used to override the event-source object's handler. - comment out all the auxiliary/want-to-have event source selection methods for now.
2022-11-04 20:28:45 +00:00
Assign callbacks for rescaling and resampling y-axis data
automatically based on data contents and ``ViewBox`` state.
'''
if src_vb is None:
src_vb = self
Re-work chart-overlay event broadcasting Drop all attempts at rewiring `ViewBox` signals, monkey-patching relayee handlers, and generally modifying event source public attributes. Instead take a much simpler approach where the event source graphics object simply has it's handler dynamically overridden by a broadcaster function which relays to all consumers using a Python loop. The benefits of this much simplified approach include: - avoiding the tedious and often complex (re)connection of signals between the source plot and the overlayed consumers. - requiring zero modification of the public interface of any of the publisher or consumer `ViewBox`s, no decoration, extra signal definitions (eg. previous `mouseDragEventRelay` or the like). - only a single dynamic method override on the event source graphics object (`ViewBox`) which does the broadcasting work and requires no modification to handler implementations. Detailed `.ui._overlay` changes: - drop `mk_relay_signal()`, `enable_relays()` which removes signal/slot hacking methodology. - drop unused `ComposedGridLayout.grid` and `.reverse`, change some method names: `.insert()` -> `.insert_plotitem()`, `append()` -> `.append_plotitem()`. - in `PlotOverlay`, again drop all signal/slot rewiring in `.add_plotitem()` and instead add our new closure based python-loop in `broadcast()` routine which is used to override the event-source object's handler. - comment out all the auxiliary/want-to-have event source selection methods for now.
2022-11-04 20:28:45 +00:00
# re-sampling trigger:
# TODO: a smarter way to avoid calling this needlessly?
# 2 things i can think of:
# - register downsample-able graphics specially and only
# iterate those.
Re-work chart-overlay event broadcasting Drop all attempts at rewiring `ViewBox` signals, monkey-patching relayee handlers, and generally modifying event source public attributes. Instead take a much simpler approach where the event source graphics object simply has it's handler dynamically overridden by a broadcaster function which relays to all consumers using a Python loop. The benefits of this much simplified approach include: - avoiding the tedious and often complex (re)connection of signals between the source plot and the overlayed consumers. - requiring zero modification of the public interface of any of the publisher or consumer `ViewBox`s, no decoration, extra signal definitions (eg. previous `mouseDragEventRelay` or the like). - only a single dynamic method override on the event source graphics object (`ViewBox`) which does the broadcasting work and requires no modification to handler implementations. Detailed `.ui._overlay` changes: - drop `mk_relay_signal()`, `enable_relays()` which removes signal/slot hacking methodology. - drop unused `ComposedGridLayout.grid` and `.reverse`, change some method names: `.insert()` -> `.insert_plotitem()`, `append()` -> `.append_plotitem()`. - in `PlotOverlay`, again drop all signal/slot rewiring in `.add_plotitem()` and instead add our new closure based python-loop in `broadcast()` routine which is used to override the event-source object's handler. - comment out all the auxiliary/want-to-have event source selection methods for now.
2022-11-04 20:28:45 +00:00
# - only register this when certain downsample-able graphics are
# "added to scene".
# src_vb.sigRangeChangedManually.connect(
# self.interact_graphics_cycle
# )
# widget-UIs/splitter(s) resizing
src_vb.sigResized.connect(
self.interact_graphics_cycle
)
def disable_auto_yrange(self) -> None:
# XXX: not entirely sure why we can't de-reg this..
self.sigResized.disconnect(
self.interact_graphics_cycle
)
def x_uppx(self) -> float:
'''
Return the "number of x units" within a single
pixel currently being displayed for relevant
graphics items which are our children.
'''
graphics = [f.graphics for f in self._chart._vizs.values()]
if not graphics:
return 0
2022-04-06 16:13:05 +00:00
for graphic in graphics:
xvec = graphic.pixelVectors()[0]
if xvec:
return xvec.x()
else:
return 0
def interact_graphics_cycle(
self,
*args, # capture Qt signal (slot) inputs
# debug_print: bool = False,
do_linked_charts: bool = True,
do_overlay_scaling: bool = True,
yrange_kwargs: dict[
str,
tuple[float, float],
] | None = None,
):
profiler = Profiler(
msg=f'ChartView.interact_graphics_cycle() for {self.name}',
disabled=not pg_profile_enabled(),
ms_threshold=ms_slower_then,
# XXX: important to avoid not seeing underlying
# ``Viz.update_graphics()`` nested profiling likely
# due to the way delaying works and garbage collection of
# the profiler in the delegated method calls.
delayed=True,
# for hardcore latency checking, comment these flags above.
# disabled=False,
# ms_threshold=4,
)
2022-09-06 12:36:28 +00:00
linked = self.linked
if (
do_linked_charts
and linked
):
plots = {linked.chart.name: linked.chart}
Re-work chart-overlay event broadcasting Drop all attempts at rewiring `ViewBox` signals, monkey-patching relayee handlers, and generally modifying event source public attributes. Instead take a much simpler approach where the event source graphics object simply has it's handler dynamically overridden by a broadcaster function which relays to all consumers using a Python loop. The benefits of this much simplified approach include: - avoiding the tedious and often complex (re)connection of signals between the source plot and the overlayed consumers. - requiring zero modification of the public interface of any of the publisher or consumer `ViewBox`s, no decoration, extra signal definitions (eg. previous `mouseDragEventRelay` or the like). - only a single dynamic method override on the event source graphics object (`ViewBox`) which does the broadcasting work and requires no modification to handler implementations. Detailed `.ui._overlay` changes: - drop `mk_relay_signal()`, `enable_relays()` which removes signal/slot hacking methodology. - drop unused `ComposedGridLayout.grid` and `.reverse`, change some method names: `.insert()` -> `.insert_plotitem()`, `append()` -> `.append_plotitem()`. - in `PlotOverlay`, again drop all signal/slot rewiring in `.add_plotitem()` and instead add our new closure based python-loop in `broadcast()` routine which is used to override the event-source object's handler. - comment out all the auxiliary/want-to-have event source selection methods for now.
2022-11-04 20:28:45 +00:00
plots |= linked.subplots
else:
chart = self._chart
plots = {chart.name: chart}
Re-work chart-overlay event broadcasting Drop all attempts at rewiring `ViewBox` signals, monkey-patching relayee handlers, and generally modifying event source public attributes. Instead take a much simpler approach where the event source graphics object simply has it's handler dynamically overridden by a broadcaster function which relays to all consumers using a Python loop. The benefits of this much simplified approach include: - avoiding the tedious and often complex (re)connection of signals between the source plot and the overlayed consumers. - requiring zero modification of the public interface of any of the publisher or consumer `ViewBox`s, no decoration, extra signal definitions (eg. previous `mouseDragEventRelay` or the like). - only a single dynamic method override on the event source graphics object (`ViewBox`) which does the broadcasting work and requires no modification to handler implementations. Detailed `.ui._overlay` changes: - drop `mk_relay_signal()`, `enable_relays()` which removes signal/slot hacking methodology. - drop unused `ComposedGridLayout.grid` and `.reverse`, change some method names: `.insert()` -> `.insert_plotitem()`, `append()` -> `.append_plotitem()`. - in `PlotOverlay`, again drop all signal/slot rewiring in `.add_plotitem()` and instead add our new closure based python-loop in `broadcast()` routine which is used to override the event-source object's handler. - comment out all the auxiliary/want-to-have event source selection methods for now.
2022-11-04 20:28:45 +00:00
# TODO: a faster single-loop-iterator way of doing this?
return overlay_viewlists(
self._viz,
plots,
profiler,
do_overlay_scaling=do_overlay_scaling,
do_linked_charts=do_linked_charts,
yrange_kwargs=yrange_kwargs,
)