# 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 . ''' Chart view box primitives ''' from __future__ import annotations from contextlib import ( asynccontextmanager, ExitStack, ) from functools import partial import time from typing import ( Callable, TYPE_CHECKING, ) import pyqtgraph as pg # NOTE XXX: pg is super annoying and re-implements it's own mouse # event subsystem.. we should really look into re-working/writing # this down the road.. Bo from pyqtgraph.GraphicsScene import mouseEvents as mevs # from pyqtgraph.GraphicsScene.mouseEvents import MouseDragEvent from pyqtgraph import ( ViewBox, Point, QtCore, functions as fn, ) import numpy as np import tractor import trio from piker.ui.qt import ( QWheelEvent, QGraphicsSceneMouseEvent as gs_mouse, Qt, QEvent, ) from ..log import get_logger from ..toolz import ( Profiler, pg_profile_enabled, ms_slower_then, ) from .view_mode import overlay_viewlists # from ._style import _min_points_to_show from ._editors import SelectRect from . import _event if TYPE_CHECKING: # from ._search import ( # SearchWidget, # ) from ._chart import ( ChartnPane, ChartPlotWidget, GodWidget, ) from ._dataviz import Viz from .order_mode import ( OrderMode, Dialog, ) from ._display import DisplayState log = get_logger(__name__) NUMBER_LINE = { Qt.Key.Key_1, Qt.Key.Key_2, Qt.Key.Key_3, Qt.Key.Key_4, Qt.Key.Key_5, Qt.Key.Key_6, Qt.Key.Key_7, Qt.Key.Key_8, Qt.Key.Key_9, Qt.Key.Key_0, } ORDER_MODE = { Qt.Key.Key_A, Qt.Key.Key_F, Qt.Key.Key_D, } async def handle_viewmode_kb_inputs( view: ChartView, recv_chan: trio.abc.ReceiveChannel, dss: dict[str, DisplayState], ) -> None: order_mode: OrderMode = view.order_mode godw: GodWidget = order_mode.godw # noqa # 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'View-mode kb-msg received,\n' f'mods: {mods!r}\n' f'key: {key!r}\n' f'text: {text!r}\n' ) 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, with ctrl-p (for "pause") if ( ctrl and key in { Qt.Key_P, } ): feed = order_mode.feed # noqa chart = order_mode.chart # noqa viz = chart.main_viz # noqa vlm_chart = chart.linked.subplots['volume'] # noqa vlm_viz = vlm_chart.main_viz # noqa dvlm_pi = vlm_chart._vizs['dolla_vlm'].plot # noqa await tractor.pause() view.interact_graphics_cycle() # FORCE graphics reset-and-render of all currently # shown data `Viz`s for the current chart app. if ( ctrl and key in { Qt.Key_R, } ): fqme: str ds: DisplayState for fqme, ds in dss.items(): viz: Viz for tf, viz in { 60: ds.hist_viz, 1: ds.viz, }.items(): # TODO: only allow this when the data is IN VIEW! # also, we probably can do this more efficiently # / smarter by only redrawing the portion of the # path necessary? viz.reset_graphics() # ------ - ------ # SEARCH MODE # ------ - ------ # ctlr-/ for "lookup", "search" -> open search tree if ( ctrl and key in { Qt.Key_L, # Qt.Key_Space, } ): godw = view._chart.linked.godwidget godw.hist_linked.resize_sidepanes(from_linked=godw.rt_linked) 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() view.linked.focus() # cancel order or clear graphics if ( key == Qt.Key_C or key == Qt.Key_Delete ): # log.info('Handling hotkey!') try: dialogs: list[Dialog] = order_mode.cancel_orders_under_cursor() except BaseException: log.exception('Failed to cancel orders !?\n') await tractor.pause() if not dialogs: log.warning( 'No orders were cancelled?\n' 'Is there an order-line under the cursor?\n' 'If you think there IS your DE might be "hiding the mouse" before ' 'we rx the keyboard input via Qt..\n' '=> Check your DE and/or TWM settings to be sure! <=\n' ) # ^TODO?, some way to detect if there's lines and # the DE is cuckin with things? # await tractor.pause() # View modes if ( ctrl and ( key == Qt.Key_Equal or key == Qt.Key_I ) ): view.wheelEvent( ev=None, axis=None, delta=view.def_delta, ) elif ( ctrl and ( key == Qt.Key_Minus or key == Qt.Key_O ) ): view.wheelEvent( ev=None, axis=None, delta=-view.def_delta, ) elif ( not ctrl and 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): view.linked.cursor.in_query_mode = True else: 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) # Toggle position config pane if ( ctrl and key in { Qt.Key_Space, } ): # searchw: SearchWidget = godw.search # pp_pane = order_mode.current_pp.pane qframes: list[ChartnPane] = [] for linked in ( godw.rt_linked, godw.hist_linked, ): for chartw in ( [linked.chart] + list(linked.subplots.values()) ): qframes.append( chartw.qframe ) # NOTE: place priority on FIRST hiding all # panes before showing them. # TODO: make this more "fancy"? # - maybe look at majority of hidden states and then # flip based on that? # - move these loops into the chart APIs? # - store the UX-state for a given feed/symbol and # apply when opening a new one (eg. if panes were # hidden then also hide them on newly loaded mkt # feeds). if not any( qf.sidepane.isHidden() for qf in qframes ): for qf in qframes: qf.sidepane.hide() else: for qf in qframes: qf.sidepane.show() # ORDER MODE # ---------- # live vs. dark trigger + an action {buy, sell, alert} order_keys_pressed = ORDER_MODE.intersection(pressed) if order_keys_pressed: # 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.cumsize: tracker.nav.show() # TODO: show pp config mini-params in status bar widget # mode.pp_config.show() 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 '' 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 ) 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) pp_pane = order_mode.pane pp_pane.on_ui_settings_change('slots', num) edit = pp_pane.form.fields['slots'] edit.selectAll() # un-highlight on ctrl release on_next_release = edit.deselect pp_pane.update_status_ui(pp_pane.order_mode.current_pp) else: # none active # hide pp label order_mode.current_pp.nav.hide_info() # if none are pressed, remove "staged" level # line under cursor position order_mode.lines.unstage_line() if view.hasFocus(): # update mode label 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, dss: dict[str, DisplayState], ) -> 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 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 ''' mode_name: str = 'view' def_delta: float = 616 * 6 def_scale_factor: float = 1.016 ** (def_delta * -1 / 20) # annots: dict[int, GraphicsObject] = {} def __init__( self, name: str, parent: pg.PlotItem = None, static_yrange: tuple[float, float] | None = None, **kwargs, ): super().__init__( parent=parent, name=name, # 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 # disable vertical scrolling self.setMouseEnabled( x=True, y=True, ) self.linked = None self._chart: ChartPlotWidget | None = None # noqa # add our selection box annotator self.select_box = SelectRect(self) # self.select_box.add_to_view(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: ''' 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, ) -> None: ''' Signal the end of a click-drag interaction to any waiters. ''' 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, **handler_kwargs, ) -> ChartView: async with ( _event.open_handlers( [self], event_types={ QEvent.KeyPress, QEvent.KeyRelease, }, async_handler=partial( handle_viewmode_kb_inputs, **handler_kwargs, ), ), _event.open_handlers( [self], event_types={ gs_mouse.GraphicsSceneMousePress, }, async_handler=partial( handle_viewmode_mouse, **handler_kwargs, ), ), ): 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: QWheelEvent | None = None, axis: int | None = None, delta: float | None = None, ): ''' Override "center-point" location for scrolling. 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 ''' # NOTE: certain operations are only avail when this handler is # actually called on events. if ev is None: assert delta assert axis is None linked = self.linked if ( not linked ): return if axis in (0, 1): mask = [False, False] mask[axis] = self.state['mouseEnabled'][axis] else: mask: list[bool] = self.state['mouseEnabled'][:] chart = self.linked.chart # don't zoom more then the min points setting viz = chart.get_viz(chart.name) _, vl, lbar, rbar, vr, r = viz.datums_range() # 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 # actual scaling factor delta: float = ev.delta() if ev else delta scale_factor: float = 1.016 ** (delta * -1 / 20) # NOTE: if elem is False -> None meaning "do not scale that # axis". scales: list[float | bool] = [ (None if m is False else scale_factor) for m in mask ] if ( # zoom happened on axis axis == 1 # 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 center = Point( fn.invertQTransform( self.childGroup.transform() ).map(ev.pos()) ) # scale_y = 1.3 ** (center.y() * -1 / 20) self.scaleBy(scales, center) # zoom in view-box area else: # use right-most point of current curve graphic xl = viz.graphics.x_last() focal = min( xl, r, ) self._resetTarget() # NOTE: scroll "around" the right most datum-element in view # gives the feeling of staying "pinned" in place. self.scaleBy(scales, 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() if ev: ev.accept() def mouseDragEvent( self, ev: mevs.MouseDragEvent, axis: int | None = None, ) -> None: pos: Point = ev.pos() lastPos: Point = ev.lastPos() dif: Point = (pos - lastPos) * -1 # dif: Point = pos - lastPos # dif: Point = dif * -1 # NOTE: if axis is specified, event will only affect that axis. btn = ev.button() # Ignore axes if mouse is disabled mouseEnabled = np.array( self.state['mouseEnabled'], dtype=float, ) mask = mouseEnabled.copy() if axis is not None: mask[1-axis] = 0.0 # Scale or translate based on mouse button if btn & ( 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' # scale_y = 1.3 ** (dif.y() * -1 / 20) # self.setLimits(yMin=None, yMax=None) # # print(scale_y) # self.scaleBy((0, scale_y)) # SELECTION MODE if ( self.state['mouseMode'] == ViewBox.RectMode and axis is None ): # XXX: WHY ev.accept() down_pos: Point = ev.buttonDownPos( btn=btn, ) scen_pos: Point = ev.scenePos() scen_down_pos: Point = ev.buttonDownScenePos( btn=btn, ) # This is the final position in the drag if ev.isFinish(): # import pdbp; pdbp.set_trace() # NOTE: think of this as a `.mouse_drag_release()` # (bc HINT that's what i called the shit ass # method that wrapped this call [yes, as a single # fucking call] originally.. you bish, guille) # Bo.. oraleeee self.select_box.set_scen_pos( # down_pos, # pos, scen_down_pos, scen_pos, ) # this is the zoom transform cmd ax = QtCore.QRectF(down_pos, pos) ax = self.childGroup.mapRectFromParent(ax) # self.showAxRect(ax) # axis history tracking self.axHistoryPointer += 1 self.axHistory = self.axHistory[ :self.axHistoryPointer] + [ax] else: self.select_box.set_scen_pos( # down_pos, # pos, scen_down_pos, scen_pos, ) # update shape of scale box # self.updateScaleBox(ev.buttonDownPos(), ev.pos()) # breakpoint() # 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() if x is not None or y is not None: self.translateBy(x=x, y=y) # 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 elif btn & 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: ''' 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-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. # - 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 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, ) linked = self.linked if ( do_linked_charts and linked ): plots = {linked.chart.name: linked.chart} plots |= linked.subplots else: chart = self._chart plots = {chart.name: chart} # 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, )