From 8911c3c8ed5b10dad2fb78647787aafb061b13cf Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 14 Jan 2022 09:14:58 -0500 Subject: [PATCH 01/14] Add support for "humanized" axes tick values --- piker/ui/_axes.py | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index 67df0138..bac17124 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -18,7 +18,7 @@ Chart axes graphics and behavior. """ -import functools +from functools import partial from typing import List, Tuple, Optional from math import floor @@ -29,6 +29,7 @@ from PyQt5.QtCore import QPointF from ._style import DpiAwareFont, hcolor, _font from ..data._source import float_digits +from ..calc import humanize _axis_pen = pg.mkPen(hcolor('bracket')) @@ -92,6 +93,18 @@ class Axis(pg.AxisItem): class PriceAxis(Axis): + def __init__( + self, + *args, + humanize: bool = True, + digits: int = 2, + **kwargs + + ) -> None: + super().__init__(*args, **kwargs) + self.humanize = humanize + self.digits = digits + def size_to_values(self) -> None: self.setWidth(self.typical_br.width()) @@ -103,20 +116,25 @@ class PriceAxis(Axis): scale, spacing, ): - - # TODO: figure out how to enforce min tick spacing by passing - # it into the parent type - digits = max(float_digits(spacing * scale), self._min_tick) + # TODO: figure out how to enforce min tick spacing by passing it + # into the parent type + digits = max( + float_digits(spacing * scale), + self._min_tick, + ) # print(f'vals: {vals}\nscale: {scale}\nspacing: {spacing}') # print(f'digits: {digits}') - return [ - ('{value:,.{digits}f}').format( - digits=digits, - value=v, - ).replace(',', ' ') for v in vals - ] + if not self.humanize: + return [ + ('{value:,.{digits}f}').format( + digits=digits, + value=v, + ).replace(',', ' ') for v in vals + ] + else: + return list(map(partial(humanize, digits=self.digits), vals)) class DynamicDateAxis(Axis): From 80079105fc5f79ca1d78aa63ab85636231b34255 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 15 Jan 2022 19:36:23 -0500 Subject: [PATCH 02/14] Add custom `.formatter` support to our `PriceAxis` Allow passing in a formatter function for processing tick values on an axis. This makes it easy to for example, `piker.calc.humanize()` dollar volume on a subchart. Factor `set_min_tick()` into the `PriceAxis` since it's not used on any x-axis data thus far. --- piker/ui/_axes.py | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index bac17124..8fa21403 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -19,7 +19,7 @@ Chart axes graphics and behavior. """ from functools import partial -from typing import List, Tuple, Optional +from typing import List, Tuple, Optional, Callable from math import floor import pandas as pd @@ -29,7 +29,6 @@ from PyQt5.QtCore import QPointF from ._style import DpiAwareFont, hcolor, _font from ..data._source import float_digits -from ..calc import humanize _axis_pen = pg.mkPen(hcolor('bracket')) @@ -43,7 +42,6 @@ class Axis(pg.AxisItem): self, linkedsplits, typical_max_str: str = '100 000.000', - min_tick: int = 2, **kwargs ) -> None: @@ -53,7 +51,6 @@ class Axis(pg.AxisItem): # self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) self.linkedsplits = linkedsplits - self._min_tick = min_tick self._dpi_font = _font self.setTickFont(_font.font) @@ -84,9 +81,6 @@ class Axis(pg.AxisItem): def size_to_values(self) -> None: pass - def set_min_tick(self, size: int) -> None: - self._min_tick = size - def txt_offsets(self) -> Tuple[int, int]: return tuple(self.style['tickTextOffset']) @@ -96,26 +90,34 @@ class PriceAxis(Axis): def __init__( self, *args, - humanize: bool = True, - digits: int = 2, + min_tick: int = 2, + formatter: Optional[Callable[[float], str]] = None, **kwargs ) -> None: super().__init__(*args, **kwargs) - self.humanize = humanize - self.digits = digits + self.formatter = formatter + self._min_tick: int = min_tick + + def set_min_tick( + self, + size: int + ) -> None: + self._min_tick = size def size_to_values(self) -> None: + # self.typical_br = _font._qfm.boundingRect(typical_max_str) self.setWidth(self.typical_br.width()) # XXX: drop for now since it just eats up h space def tickStrings( self, - vals, - scale, - spacing, - ): + vals: tuple[float], + scale: float, + spacing: float, + + ) -> list[str]: # TODO: figure out how to enforce min tick spacing by passing it # into the parent type digits = max( @@ -126,7 +128,7 @@ class PriceAxis(Axis): # print(f'vals: {vals}\nscale: {scale}\nspacing: {spacing}') # print(f'digits: {digits}') - if not self.humanize: + if not self.formatter: return [ ('{value:,.{digits}f}').format( digits=digits, @@ -134,7 +136,7 @@ class PriceAxis(Axis): ).replace(',', ' ') for v in vals ] else: - return list(map(partial(humanize, digits=self.digits), vals)) + return list(map(self.formatter, vals)) class DynamicDateAxis(Axis): @@ -154,6 +156,7 @@ class DynamicDateAxis(Axis): def _indexes_to_timestrs( self, indexes: List[int], + ) -> List[str]: chart = self.linkedsplits.chart @@ -183,9 +186,10 @@ class DynamicDateAxis(Axis): def tickStrings( self, values: tuple[float], - scale, - spacing, - ): + scale: float, + spacing: float, + + ) -> list[str]: # info = self.tickStrings.cache_info() # print(info) return self._indexes_to_timestrs(values) From 349040dbf07f879fd0f66c40917228ccb7f91f2e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 15 Jan 2022 20:24:54 -0500 Subject: [PATCH 03/14] Revert cursor rate limit settings --- piker/ui/_cursor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/piker/ui/_cursor.py b/piker/ui/_cursor.py index 3833a123..8f91f694 100644 --- a/piker/ui/_cursor.py +++ b/piker/ui/_cursor.py @@ -43,8 +43,8 @@ log = get_logger(__name__) # latency (in terms of perceived lag in cross hair) so really be sure # there's an improvement if you want to change it! -_mouse_rate_limit = 58 # TODO; should we calc current screen refresh rate? -_debounce_delay = 1 / 60 +_mouse_rate_limit = 120 # TODO; should we calc current screen refresh rate? +_debounce_delay = 1 / 40 _ch_label_opac = 1 From 94b6f370a9db853580bb4b70d9c56e834a2549fd Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 17 Jan 2022 09:46:17 -0500 Subject: [PATCH 04/14] Allow axis kwargs passthrough --- piker/ui/_chart.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index dd665fde..3600c665 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -862,6 +862,7 @@ class ChartPlotWidget(pg.PlotWidget): def overlay_plotitem( self, name: str, + axis_kwargs: dict = {}, ) -> pg.PlotItem: # Custom viewbox impl @@ -875,6 +876,7 @@ class ChartPlotWidget(pg.PlotWidget): yaxis = PriceAxis( orientation='right', linkedsplits=self.linked, + **axis_kwargs, ) plotitem = pg.PlotItem( From ce7c1740595d702500223feea290013930edd03f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 17 Jan 2022 18:42:24 -0500 Subject: [PATCH 05/14] Add `Axis.set_title()` for hipper labelling Use our internal `Label` with much better dpi based sizing of text and placement below the y-axis ticks area for more minimalism and less clutter. Play around with `lru_cache` on axis label bounding rects and for now just hack sizing by subtracting half the text height (not sure why) from the width to avoid over-extension / overlap with any adjacent axis. --- piker/ui/_axes.py | 94 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 16 deletions(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index 8fa21403..5cc89bf3 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -18,7 +18,7 @@ Chart axes graphics and behavior. """ -from functools import partial +from functools import lru_cache from typing import List, Tuple, Optional, Callable from math import floor @@ -27,8 +27,10 @@ import pyqtgraph as pg from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import QPointF -from ._style import DpiAwareFont, hcolor, _font from ..data._source import float_digits +from ._label import Label +from ._style import DpiAwareFont, hcolor, _font +from ._interaction import ChartView _axis_pen = pg.mkPen(hcolor('bracket')) @@ -72,7 +74,10 @@ class Axis(pg.AxisItem): }) self.setTickFont(_font.font) + # NOTE: this is for surrounding "border" self.setPen(_axis_pen) + # this is the text color + self.setTextPen(_axis_pen) self.typical_br = _font._qfm.boundingRect(typical_max_str) # size the pertinent axis dimension to a "typical value" @@ -91,6 +96,7 @@ class PriceAxis(Axis): self, *args, min_tick: int = 2, + title: str = '', formatter: Optional[Callable[[float], str]] = None, **kwargs @@ -98,6 +104,43 @@ class PriceAxis(Axis): super().__init__(*args, **kwargs) self.formatter = formatter self._min_tick: int = min_tick + self.title = None + + def set_title( + self, + title: str, + view: Optional[ChartView] = None + + ) -> Label: + ''' + Set a sane UX label using our built-in ``Label``. + + ''' + # XXX: built-in labels but they're huge, and placed weird.. + # self.setLabel(title) + # self.showLabel() + + label = self.title = Label( + view=view or self.linkedView(), + fmt_str=title, + color='bracket', + parent=self, + # update_on_range_change=False, + ) + + def below_axis() -> QPointF: + return QPointF( + 0, + self.size().height(), + ) + + # XXX: doesn't work? have to pass it above + # label.txt.setParent(self) + label.scene_anchor = below_axis + label.render() + label.show() + label.update() + return label def set_min_tick( self, @@ -124,6 +167,8 @@ class PriceAxis(Axis): float_digits(spacing * scale), self._min_tick, ) + if self.title: + self.title.update() # print(f'vals: {vals}\nscale: {scale}\nspacing: {spacing}') # print(f'digits: {digits}') @@ -291,9 +336,10 @@ class AxisLabel(pg.GraphicsObject): def boundingRect(self): # noqa - """Size the graphics space from the text contents. + ''' + Size the graphics space from the text contents. - """ + ''' if self.label_str: self._size_br_from_str(self.label_str) @@ -309,23 +355,32 @@ class AxisLabel(pg.GraphicsObject): return QtCore.QRectF() - # return self.rect or QtCore.QRectF() + # TODO: but the input probably needs to be the "len" of + # the current text value: + @lru_cache + def _size_br_from_str( + self, + value: str - def _size_br_from_str(self, value: str) -> None: - """Do our best to render the bounding rect to a set margin + ) -> tuple[float, float]: + ''' + Do our best to render the bounding rect to a set margin around provided string contents. - """ + ''' # size the filled rect to text and/or parent axis # if not self._txt_br: - # # XXX: this can't be c + # # XXX: this can't be called until stuff is rendered? # self._txt_br = self._dpifont.boundingRect(value) txt_br = self._txt_br = self._dpifont.boundingRect(value) txt_h, txt_w = txt_br.height(), txt_br.width() + # print(f'wsw: {self._dpifont.boundingRect(" ")}') # allow subtypes to specify a static width and height h, w = self.size_hint() + # print(f'axis size: {self._parent.size()}') + # print(f'axis geo: {self._parent.geometry()}') self.rect = QtCore.QRectF( 0, 0, @@ -336,7 +391,7 @@ class AxisLabel(pg.GraphicsObject): # hb = self.path.controlPointRect() # hb_size = hb.size() - return self.rect + return (self.rect.width(), self.rect.height()) # _common_text_flags = ( # QtCore.Qt.TextDontClip | @@ -432,8 +487,12 @@ class YAxisLabel(AxisLabel): self.x_offset, y_offset = self._parent.txt_offsets() def size_hint(self) -> Tuple[float, float]: - # size to parent axis width - return None, self._parent.width() + # size to parent axis width(-ish) + wsh = self._dpifont.boundingRect(' ').height() / 2 + return ( + None, + self._parent.size().width() - wsh, + ) def update_label( self, @@ -461,9 +520,10 @@ class YAxisLabel(AxisLabel): self.update() def update_on_resize(self, vr, r): - """Tiis is a ``.sigRangeChanged()`` handler. + ''' + This is a ``.sigRangeChanged()`` handler. - """ + ''' index, last = self._last_datum if index is not None: self.update_from_data(index, last) @@ -473,11 +533,13 @@ class YAxisLabel(AxisLabel): index: int, value: float, _save_last: bool = True, + ) -> None: - """Update the label's text contents **and** position from + ''' + Update the label's text contents **and** position from a view box coordinate datum. - """ + ''' if _save_last: self._last_datum = (index, value) From f011234285d9c18495e737b66512c9a9642a61da Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 17 Jan 2022 18:51:58 -0500 Subject: [PATCH 06/14] Type annot and docs updates in anchors mod --- piker/ui/_anchors.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/piker/ui/_anchors.py b/piker/ui/_anchors.py index 19a2013b..e7b6d72b 100644 --- a/piker/ui/_anchors.py +++ b/piker/ui/_anchors.py @@ -18,21 +18,26 @@ Anchor funtions for UI placement of annotions. ''' -from typing import Callable +from __future__ import annotations +from typing import Callable, TYPE_CHECKING from PyQt5.QtCore import QPointF from PyQt5.QtWidgets import QGraphicsPathItem -from ._label import Label +if TYPE_CHECKING: + from ._axes import PriceAxis + from ._chart import ChartPlotWidget + from ._label import Label def marker_right_points( - - chart: 'ChartPlotWidget', # noqa + chart: ChartPlotWidget, # noqa marker_size: int = 20, ) -> (float, float, float): - '''Return x-dimension, y-axis-aware, level-line marker oriented scene values. + ''' + Return x-dimension, y-axis-aware, level-line marker oriented scene + values. X values correspond to set the end of a level line, end of a paried level line marker, and the right most side of the "right" @@ -57,16 +62,17 @@ def vbr_left( label: Label, ) -> Callable[..., float]: - """Return a closure which gives the scene x-coordinate for the - leftmost point of the containing view box. + ''' + Return a closure which gives the scene x-coordinate for the leftmost + point of the containing view box. - """ + ''' return label.vbr().left def right_axis( - chart: 'ChartPlotWidget', # noqa + chart: ChartPlotWidget, # noqa label: Label, side: str = 'left', @@ -141,13 +147,13 @@ def gpath_pin( return path_br.bottomRight() - QPointF(label.w, label.h / 6) - def pp_tight_and_right( label: Label ) -> QPointF: - '''Place *just* right of the pp label. + ''' + Place *just* right of the pp label. ''' - txt = label.txt + # txt = label.txt return label.txt.pos() + QPointF(label.w - label.h/3, 0) From 5c2d3125b477a236a64b368e80db7dcb43a8c47e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 17 Jan 2022 18:56:58 -0500 Subject: [PATCH 07/14] Add vlm axis titles and humanized $vlm y-range --- piker/ui/_fsp.py | 139 ++++++++++++++++++++++++++++------------------- 1 file changed, 83 insertions(+), 56 deletions(-) diff --git a/piker/ui/_fsp.py b/piker/ui/_fsp.py index 12fe9a69..ea4b46cb 100644 --- a/piker/ui/_fsp.py +++ b/piker/ui/_fsp.py @@ -33,7 +33,9 @@ import pyqtgraph as pg import trio from trio_typing import TaskStatus +from ._axes import PriceAxis from .._cacheables import maybe_open_context +from ..calc import humanize from ..data._sharedmem import ( ShmArray, maybe_open_shm_array, @@ -661,73 +663,98 @@ async def open_vlm_displays( # size view to data once at outset chart.view._set_yrange() - if not dvlm: - return + # add axis title + axis = chart.getAxis('right') + axis.set_title(' vlm') - # spawn and overlay $ vlm on the same subchart - shm, started = await admin.start_engine_task( - 'dolla_vlm', - # linked.symbol.front_feed(), # data-feed symbol key - { # fsp engine conf - 'func_name': 'dolla_vlm', - 'zero_on_step': True, - 'params': { - 'price_func': { - 'default_value': 'chl3', + if dvlm: + + # spawn and overlay $ vlm on the same subchart + shm, started = await admin.start_engine_task( + 'dolla_vlm', + # linked.symbol.front_feed(), # data-feed symbol key + { # fsp engine conf + 'func_name': 'dolla_vlm', + 'zero_on_step': True, + 'params': { + 'price_func': { + 'default_value': 'chl3', + }, }, }, - }, - # loglevel, - ) - # profiler(f'created shm for fsp actor: {display_name}') + # loglevel, + ) + # profiler(f'created shm for fsp actor: {display_name}') - await started.wait() + await started.wait() - pi = chart.overlay_plotitem( - 'dolla_vlm', - ) - # add custom auto range handler - pi.vb._maxmin = partial(maxmin, name='dolla_vlm') + pi = chart.overlay_plotitem( + 'dolla_vlm', + axis_kwargs={ + # 'humanize': True, + # 'text': 'dvlm', + 'typical_max_str': ' 99.9 M ', + 'formatter': partial( + humanize, + digits=2, + ), + }, - curve, _ = chart.draw_curve( + ) - name='dolla_vlm', - data=shm.array, + # add axis title + raxis = pi.getAxis('right') + raxis.set_title(' $vlm', view=pi.getViewBox()) - array_key='dolla_vlm', - overlay=pi, - color='charcoal', - step_mode=True, - # **conf.get('chart_kwargs', {}) - ) - # TODO: is there a way to "sync" the dual axes such that only - # one curve is needed? - # curve.hide() + # add custom auto range handler + pi.vb._maxmin = partial(maxmin, name='dolla_vlm') - # TODO: we need a better API to do this.. - # specially store ref to shm for lookup in display loop - # since only a placeholder of `None` is entered in - # ``.draw_curve()``. - chart._overlays['dolla_vlm'] = shm + curve, _ = chart.draw_curve( - # XXX: old dict-style config before it was moved into the helper task - # 'dolla_vlm': { - # 'func_name': 'dolla_vlm', - # 'zero_on_step': True, - # 'overlay': 'volume', - # 'separate_axes': True, - # 'params': { - # 'price_func': { - # 'default_value': 'chl3', - # # tell target ``Edit`` widget to not allow - # # edits for now. - # 'widget_kwargs': {'readonly': True}, - # }, - # }, - # 'chart_kwargs': {'step_mode': True} - # }, + name='dolla_vlm', + data=shm.array, - # } + array_key='dolla_vlm', + overlay=pi, + color='charcoal', + step_mode=True, + # **conf.get('chart_kwargs', {}) + ) + # TODO: is there a way to "sync" the dual axes such that only + # one curve is needed? + # curve.hide() + + # TODO: we need a better API to do this.. + # specially store ref to shm for lookup in display loop + # since only a placeholder of `None` is entered in + # ``.draw_curve()``. + chart._overlays['dolla_vlm'] = shm + + # XXX: old dict-style config before it was moved into the + # helper task + # 'dolla_vlm': { + # 'func_name': 'dolla_vlm', + # 'zero_on_step': True, + # 'overlay': 'volume', + # 'separate_axes': True, + # 'params': { + # 'price_func': { + # 'default_value': 'chl3', + # # tell target ``Edit`` widget to not allow + # # edits for now. + # 'widget_kwargs': {'readonly': True}, + # }, + # }, + # 'chart_kwargs': {'step_mode': True} + # }, + + # } + + for name, axis_info in pi.axes.items(): + # lol this sux XD + axis = axis_info['item'] + if isinstance(axis, PriceAxis): + axis.size_to_values() # built-in vlm fsps for display_name, conf in { From c7a588cf25dbc996051f365aa1734ca2677fdfb0 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 17 Jan 2022 19:00:04 -0500 Subject: [PATCH 08/14] Pop vlm chart from subplots to avoid double render --- piker/ui/_display.py | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index 1e1855bb..c2333350 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -115,13 +115,14 @@ async def update_linked_charts_graphics( vlm_chart: Optional[ChartPlotWidget] = None, ) -> None: - '''The 'main' (price) chart real-time update loop. + ''' + The 'main' (price) chart real-time update loop. Receive from the primary instrument quote stream and update the OHLC chart. ''' - # TODO: bunch of stuff: + # TODO: bunch of stuff (some might be done already, can't member): # - I'm starting to think all this logic should be # done in one place and "graphics update routines" # should not be doing any length checking and array diffing. @@ -181,13 +182,34 @@ async def update_linked_charts_graphics( view = chart.view last_quote = time.time() + # async def iter_drain_quotes(): + # # NOTE: all code below this loop is expected to be synchronous + # # and thus draw instructions are not picked up jntil the next + # # wait / iteration. + # async for quotes in stream: + # while True: + # try: + # moar = stream.receive_nowait() + # except trio.WouldBlock: + # yield quotes + # break + # else: + # for sym, quote in moar.items(): + # ticks_frame = quote.get('ticks') + # if ticks_frame: + # quotes[sym].setdefault( + # 'ticks', []).extend(ticks_frame) + # print('pulled extra') + + # yield quotes + + # async for quotes in iter_drain_quotes(): + async for quotes in stream: - now = time.time() quote_period = time.time() - last_quote quote_rate = round( 1/quote_period, 1) if quote_period > 0 else float('inf') - if ( quote_period <= 1/_quote_throttle_rate @@ -196,7 +218,8 @@ async def update_linked_charts_graphics( and quote_rate >= _quote_throttle_rate * 1.5 ): log.warning(f'High quote rate {symbol.key}: {quote_rate}') - last_quote = now + + last_quote = time.time() # chart isn't active/shown so skip render cycle and pause feed(s) if chart.linked.isHidden(): @@ -621,9 +644,15 @@ async def display_symbol_data( await trio.sleep(0) linkedsplits.resize_sidepanes() + # NOTE: we pop the volume chart from the subplots set so + # that it isn't double rendered in the display loop + # above since we do a maxmin calc on the volume data to + # determine if auto-range adjustements should be made. + linkedsplits.subplots.pop('volume', None) + # TODO: make this not so shit XD # close group status sbar._status_groups[loading_sym_key][1]() - # let the app run. + # let the app run.. bby await trio.sleep_forever() From f5eb34c4d72d86f3bbad3034faa25d9b2c09fc58 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 21 Jan 2022 07:32:15 -0500 Subject: [PATCH 09/14] Make axes labels more pixel perfect --- piker/ui/_axes.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index 5cc89bf3..56096b7d 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -287,6 +287,8 @@ class AxisLabel(pg.GraphicsObject): self.path = None self.rect = None + self._pw = self.pixelWidth() + def paint( self, p: QtGui.QPainter, @@ -419,6 +421,7 @@ class XAxisLabel(AxisLabel): abs_pos: QPointF, # scene coords value: float, # data for text offset: int = 0 # if have margins, k? + ) -> None: timestrs = self._parent._indexes_to_timestrs([int(value)]) @@ -433,17 +436,19 @@ class XAxisLabel(AxisLabel): w = self.boundingRect().width() - self.setPos(QPointF( - abs_pos.x() - w/2, - y_offset/2, - )) + self.setPos( + QPointF( + abs_pos.x() - w/2 - self._pw, + y_offset/2, + ) + ) self.update() def _draw_arrow_path(self): y_offset = self._parent.style['tickTextOffset'][1] path = QtGui.QPainterPath() h, w = self.rect.height(), self.rect.width() - middle = w/2 - 0.5 + middle = w/2 - self._pw * 0.5 aw = h/2 left = middle - aw right = middle + aw @@ -513,10 +518,12 @@ class YAxisLabel(AxisLabel): br = self.boundingRect() h = br.height() - self.setPos(QPointF( - x_offset, - abs_pos.y() - h / 2 - self._y_margin / 2 - )) + self.setPos( + QPointF( + x_offset, + abs_pos.y() - h / 2 - self._pw, + ) + ) self.update() def update_on_resize(self, vr, r): @@ -553,7 +560,7 @@ class YAxisLabel(AxisLabel): path = QtGui.QPainterPath() h = self.rect.height() path.moveTo(0, 0) - path.lineTo(-x_offset - h/4, h/2.) + path.lineTo(-x_offset - h/4, h/2. - self._pw/2) path.lineTo(0, h) path.closeSubpath() self.path = path From 7f4546b71fea61d5944fe6ac016cf3175420a16d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 21 Jan 2022 08:30:00 -0500 Subject: [PATCH 10/14] Use overlay api to access multi-axes by name --- piker/ui/_chart.py | 65 +++++++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 3600c665..f4a3c19e 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -479,14 +479,20 @@ class LinkedSplits(QWidget): axisItems=axes, **cpw_kwargs, ) + cpw.hideAxis('left') + cpw.hideAxis('bottom') if self.xaxis_chart: + self.xaxis_chart.hideAxis('bottom') + # 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.removeAxis('bottom', unlink=False) + # assert 'bottom' not in self.xaxis_chart.plotItem.axes + self.xaxis_chart = cpw + cpw.showAxis('bottom') if self.xaxis_chart is None: self.xaxis_chart = cpw @@ -726,11 +732,6 @@ class ChartPlotWidget(pg.PlotWidget): self._static_yrange = static_yrange # for "known y-range style" self._view_mode: str = 'follow' - # show only right side axes - self.hideAxis('left') - self.showAxis('right') - # self.showAxis('left') - # show background grid self.showGrid(x=False, y=True, alpha=0.3) @@ -862,55 +863,58 @@ class ChartPlotWidget(pg.PlotWidget): def overlay_plotitem( self, name: str, + index: Optional[int] = None, + axis_title: Optional[str] = None, + axis_side: str = 'right', axis_kwargs: dict = {}, ) -> pg.PlotItem: + # Custom viewbox impl cv = self.mk_vb(name) cv.chart = self - # xaxis = DynamicDateAxis( - # orientation='bottom', - # linkedsplits=self.linked, - # ) + allowed_sides = {'left', 'right'} + if axis_side not in allowed_sides: + raise ValueError(f'``axis_side``` must be in {allowed_sides}') yaxis = PriceAxis( - orientation='right', + orientation=axis_side, linkedsplits=self.linked, **axis_kwargs, ) - plotitem = pg.PlotItem( + pi = pg.PlotItem( parent=self.plotItem, name=name, enableMenu=False, viewBox=cv, axisItems={ # 'bottom': xaxis, - 'right': yaxis, + axis_side: yaxis, }, default_axes=[], ) - # plotitem.setAxisItems( - # add_to_layout=False, - # axisItems={ - # 'bottom': xaxis, - # 'right': yaxis, - # }, - # ) - # plotite.hideAxis('right') - # plotite.hideAxis('bottom') - # plotitem.addItem(curve) + pi.hideButtons() + cv.enable_auto_yrange() - # plotitem.enableAutoRange(axis='y') - plotitem.hideButtons() - + # compose this new plot's graphics with the current chart's + # existing one but with separate axes as neede and specified. self.pi_overlay.add_plotitem( - plotitem, + pi, + index=index, + # only link x-axes, link_axes=(0,), ) - return plotitem + + # add axis title + # TODO: do we want this API to still work? + # raxis = pi.getAxis('right') + axis = self.pi_overlay.get_axis(pi, axis_side) + axis.set_title(axis_title or name, view=pi.getViewBox()) + + return pi def draw_curve( self, @@ -1016,7 +1020,8 @@ class ChartPlotWidget(pg.PlotWidget): # add y-axis "last" value label last = self._ysticks[name] = YAxisLabel( chart=self, - parent=self.getAxis('right'), + # parent=self.getAxis('right'), + parent=self.pi_overlay.get_axis(self.plotItem, 'right'), # TODO: pass this from symbol data digits=digits, opacity=1, From fd31b843b9088721a8d0666719c8d59387d7afc2 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 21 Jan 2022 08:30:53 -0500 Subject: [PATCH 11/14] Hide the unit vlm after the $vlm is up Since more curves costs more processing and since the vlm and $vlm curves are normally very close to the same (graphically) we hide the unit volume curve once the dollar volume is up (after the fsp daemon-task is spawned) and just expect the user to understand the diff in axes units. Also, use the new `title=` api to `.overlay_plotitem()`. --- piker/ui/_fsp.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/piker/ui/_fsp.py b/piker/ui/_fsp.py index ea4b46cb..22196f23 100644 --- a/piker/ui/_fsp.py +++ b/piker/ui/_fsp.py @@ -655,7 +655,7 @@ async def open_vlm_displays( last_val_sticky.update_from_data(-1, value) - chart.update_curve_from_array( + vlm_curve = chart.update_curve_from_array( 'volume', shm.array, ) @@ -690,10 +690,11 @@ async def open_vlm_displays( pi = chart.overlay_plotitem( 'dolla_vlm', + index=0, # place axis on inside (nearest to chart) + axis_title=' $vlm', + axis_side='right', axis_kwargs={ - # 'humanize': True, - # 'text': 'dvlm', - 'typical_max_str': ' 99.9 M ', + 'typical_max_str': ' 100.0 M ', 'formatter': partial( humanize, digits=2, @@ -702,10 +703,6 @@ async def open_vlm_displays( ) - # add axis title - raxis = pi.getAxis('right') - raxis.set_title(' $vlm', view=pi.getViewBox()) - # add custom auto range handler pi.vb._maxmin = partial(maxmin, name='dolla_vlm') @@ -716,13 +713,18 @@ async def open_vlm_displays( array_key='dolla_vlm', overlay=pi, - color='charcoal', + # color='bracket', + # TODO: this color or dark volume + # color='charcoal', step_mode=True, # **conf.get('chart_kwargs', {}) ) # TODO: is there a way to "sync" the dual axes such that only # one curve is needed? - # curve.hide() + # hide the original vlm curve since the $vlm one is now + # displayed and the curves are effectively the same minus + # liquidity events (well at least on low OHLC periods - 1s). + vlm_curve.hide() # TODO: we need a better API to do this.. # specially store ref to shm for lookup in display loop From 7b21ddd27f4c2624069a262e94996265e3904466 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 21 Jan 2022 08:37:31 -0500 Subject: [PATCH 12/14] Allow passing in parent to `Label` --- piker/ui/_label.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/piker/ui/_label.py b/piker/ui/_label.py index a0081a48..699a81ae 100644 --- a/piker/ui/_label.py +++ b/piker/ui/_label.py @@ -34,7 +34,7 @@ from ._style import ( class Label: - """ + ''' A plain ol' "scene label" using an underlying ``QGraphicsTextItem``. After hacking for many days on multiple "label" systems inside @@ -50,10 +50,8 @@ class Label: small, re-usable label components that can actually be used to build production grade UIs... - """ - + ''' def __init__( - self, view: pg.ViewBox, fmt_str: str, @@ -63,6 +61,7 @@ class Label: font_size: str = 'small', opacity: float = 1, fields: dict = {}, + parent: pg.GraphicsObject = None, update_on_range_change: bool = True, ) -> None: @@ -71,11 +70,13 @@ class Label: self._fmt_str = fmt_str self._view_xy = QPointF(0, 0) - self.scene_anchor: Optional[Callable[..., QPointF]] = None + self.scene_anchor: Optional[ + Callable[..., QPointF] + ] = None self._x_offset = x_offset - txt = self.txt = QtWidgets.QGraphicsTextItem() + txt = self.txt = QtWidgets.QGraphicsTextItem(parent=parent) txt.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) vb.scene().addItem(txt) @@ -86,7 +87,6 @@ class Label: ) dpi_font.configure_to_dpi() txt.setFont(dpi_font.font) - txt.setOpacity(opacity) # register viewbox callbacks @@ -109,7 +109,7 @@ class Label: # self.setTextInteractionFlags(QtGui.Qt.TextEditorInteraction) @property - def color(self): + def color(self) -> str: return self._hcolor @color.setter @@ -118,9 +118,10 @@ class Label: self._hcolor = color def update(self) -> None: - '''Update this label either by invoking its - user defined anchoring function, or by positioning - to the last recorded data view coordinates. + ''' + Update this label either by invoking its user defined anchoring + function, or by positioning to the last recorded data view + coordinates. ''' # move label in scene coords to desired position @@ -234,7 +235,8 @@ class Label: class FormatLabel(QLabel): - '''Kinda similar to above but using the widget apis. + ''' + Kinda similar to above but using the widget apis. ''' def __init__( @@ -273,8 +275,8 @@ class FormatLabel(QLabel): QSizePolicy.Expanding, QSizePolicy.Expanding, ) - self.setAlignment(Qt.AlignVCenter - | Qt.AlignLeft + self.setAlignment( + Qt.AlignVCenter | Qt.AlignLeft ) self.setText(self.fmt_str) From 4e884aec6cab29a7e4af39180a7891eb357fad06 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 21 Jan 2022 08:39:03 -0500 Subject: [PATCH 13/14] Fix bottom axis check logic for overlays, try out some px perfection --- piker/ui/_cursor.py | 59 +++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/piker/ui/_cursor.py b/piker/ui/_cursor.py index 8f91f694..d0f642e4 100644 --- a/piker/ui/_cursor.py +++ b/piker/ui/_cursor.py @@ -369,7 +369,13 @@ class Cursor(pg.GraphicsObject): self, plot: 'ChartPlotWidget', # noqa digits: int = 0, + ) -> None: + ''' + Add chart to tracked set such that a cross-hair and possibly + curve tracking cursor can be drawn on the plot. + + ''' # add ``pg.graphicsItems.InfiniteLine``s # vertical and horizonal lines and a y-axis label @@ -382,7 +388,8 @@ class Cursor(pg.GraphicsObject): yl = YAxisLabel( chart=plot, - parent=plot.getAxis('right'), + # parent=plot.getAxis('right'), + parent=plot.pi_overlay.get_axis(plot.plotItem, 'right'), digits=digits or self.digits, opacity=_ch_label_opac, bg_color=self.label_color, @@ -424,19 +431,25 @@ class Cursor(pg.GraphicsObject): # 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( + # if 'bottom' in plot.plotItem.axes: + if plot.linked.xaxis_chart is plot: + xlabel = self.xaxis_label = XAxisLabel( parent=self.plots[plot_index].getAxis('bottom'), + # parent=self.plots[plot_index].pi_overlay.get_axis(plot.plotItem, '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))) + xlabel.setPos( + self.plots[0].mapFromView(QPointF(0, 0)) + ) + xlabel.show() def add_curve_cursor( self, plot: 'ChartPlotWidget', # noqa curve: 'PlotCurveItem', # noqa + ) -> LineDot: # if this plot contains curves add line dot "cursors" to denote # the current sample under the mouse @@ -493,24 +506,27 @@ class Cursor(pg.GraphicsObject): ix = round(x) # since bars are centered around index + # px perfect... + line_offset = self._lw / 2 + # round y value to nearest tick step m = self._y_incr_mult iy = round(y * m) / m - - # px perfect... - line_offset = self._lw / 2 + vl_y = iy - line_offset # update y-range items if iy != last_iy: if self._y_label_update: self.graphics[self.active_plot]['yl'].update_label( - abs_pos=plot.mapFromView(QPointF(ix, iy)), + # abs_pos=plot.mapFromView(QPointF(ix, iy)), + abs_pos=plot.mapFromView(QPointF(ix, vl_y)), value=iy ) # only update horizontal xhair line if label is enabled - self.graphics[plot]['hl'].setY(iy) + # self.graphics[plot]['hl'].setY(iy) + self.graphics[plot]['hl'].setY(vl_y) # update all trackers for item in self._trackers: @@ -541,21 +557,18 @@ class Cursor(pg.GraphicsObject): # left axis offset width for calcuating # absolute x-axis label placement. left_axis_width = 0 + left = axes.get('left') + if left: + left_axis_width = left['item'].width() - if 'bottom' in axes: - - left = axes.get('left') - if left: - left_axis_width = left['item'].width() - - # map back to abs (label-local) coordinates - self.xaxis_label.update_label( - abs_pos=( - plot.mapFromView(QPointF(vl_x, iy)) - - QPointF(left_axis_width, 0) - ), - value=ix, - ) + # map back to abs (label-local) coordinates + self.xaxis_label.update_label( + abs_pos=( + plot.mapFromView(QPointF(vl_x, iy)) - + QPointF(left_axis_width, 0) + ), + value=ix, + ) self._datum_xy = ix, iy From d351fe14a86d6522adbba174c0c2980ef6191595 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 21 Jan 2022 17:03:14 -0500 Subject: [PATCH 14/14] Annoying doc string(s) --- piker/fsp/_momo.py | 2 +- piker/ui/_lines.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/piker/fsp/_momo.py b/piker/fsp/_momo.py index 78461d8a..2ee55e00 100644 --- a/piker/fsp/_momo.py +++ b/piker/fsp/_momo.py @@ -1,5 +1,5 @@ # piker: trading gear for hackers -# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0) +# Copyright (C) Tyler Goodlet (in stewardship of 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 diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index acd1e88a..d148049e 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -334,10 +334,11 @@ class LevelLine(pg.InfiniteLine): w: QtWidgets.QWidget ) -> None: - """Core paint which we override (yet again) + ''' + Core paint which we override (yet again) from pg.. - """ + ''' p.setRenderHint(p.Antialiasing) # these are in viewbox coords