diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py
index 58f73d04..61669be3 100644
--- a/piker/ui/_chart.py
+++ b/piker/ui/_chart.py
@@ -30,9 +30,11 @@ from ._axes import (
DynamicDateAxis,
PriceAxis,
)
-from ._graphics import (
+from ._graphics._cursor import (
CrossHair,
ContentsLabel,
+)
+from ._graphics._lines import (
level_line,
L1Labels,
)
diff --git a/piker/ui/_graphics/__init__.py b/piker/ui/_graphics/__init__.py
index 0612dc08..2846367a 100644
--- a/piker/ui/_graphics/__init__.py
+++ b/piker/ui/_graphics/__init__.py
@@ -15,582 +15,6 @@
# along with this program. If not, see .
"""
-Chart graphics for displaying a slew of different data types.
+Internal custom graphics mostly built for low latency and reuse.
+
"""
-import inspect
-from typing import List, Optional, Tuple
-
-import numpy as np
-import pyqtgraph as pg
-from numba import jit, float64, int64 # , optional
-# from numba import types as ntypes
-from PyQt5 import QtCore, QtGui
-from PyQt5.QtCore import QLineF, QPointF
-
-# from .._profile import timeit
-# from ..data._source import numba_ohlc_dtype
-from .._style import (
- _xaxis_at,
- hcolor,
- _font,
- _down_2_font_inches_we_like,
-)
-from .._axes import YAxisLabel, XAxisLabel, YSticky
-
-
-# XXX: these settings seem to result in really decent mouse scroll
-# 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 = 60 # TODO; should we calc current screen refresh rate?
-_debounce_delay = 1 / 2e3
-_ch_label_opac = 1
-
-
-# TODO: we need to handle the case where index is outside
-# the underlying datums range
-class LineDot(pg.CurvePoint):
-
- def __init__(
- self,
- curve: pg.PlotCurveItem,
- index: int,
- plot: 'ChartPlotWidget',
- pos=None,
- size: int = 2, # in pxs
- color: str = 'default_light',
- ) -> None:
- pg.CurvePoint.__init__(
- self,
- curve,
- index=index,
- pos=pos,
- rotate=False,
- )
- self._plot = plot
-
- # TODO: get pen from curve if not defined?
- cdefault = hcolor(color)
- pen = pg.mkPen(cdefault)
- brush = pg.mkBrush(cdefault)
-
- # presuming this is fast since it's built in?
- dot = self.dot = QtGui.QGraphicsEllipseItem(
- QtCore.QRectF(-size / 2, -size / 2, size, size)
- )
- # if we needed transformable dot?
- # dot.translate(-size*0.5, -size*0.5)
- dot.setPen(pen)
- dot.setBrush(brush)
- dot.setParentItem(self)
-
- # keep a static size
- self.setFlag(self.ItemIgnoresTransformations)
-
- def event(
- self,
- ev: QtCore.QEvent,
- ) -> None:
- # print((ev, type(ev)))
- if not isinstance(ev, QtCore.QDynamicPropertyChangeEvent) or self.curve() is None:
- return False
-
- # if ev.propertyName() == 'index':
- # print(ev)
- # # self.setProperty
-
- (x, y) = self.curve().getData()
- index = self.property('index')
- # first = self._plot._ohlc[0]['index']
- # first = x[0]
- # i = index - first
- i = index - x[0]
- if i > 0 and i < len(y):
- newPos = (index, y[i])
- QtGui.QGraphicsItem.setPos(self, *newPos)
- return True
-
- return False
-
-
-_corner_anchors = {
- 'top': 0,
- 'left': 0,
- 'bottom': 1,
- 'right': 1,
-}
-# XXX: fyi naming here is confusing / opposite to coords
-_corner_margins = {
- ('top', 'left'): (-4, -5),
- ('top', 'right'): (4, -5),
-
- ('bottom', 'left'): (-4, lambda font_size: font_size * 2),
- ('bottom', 'right'): (4, lambda font_size: font_size * 2),
-}
-
-
-class ContentsLabel(pg.LabelItem):
- """Label anchored to a ``ViewBox`` typically for displaying
- datum-wise points from the "viewed" contents.
-
- """
- def __init__(
- self,
- chart: 'ChartPlotWidget', # noqa
- anchor_at: str = ('top', 'right'),
- justify_text: str = 'left',
- font_size: Optional[int] = None,
- ) -> None:
- font_size = font_size or _font.font.pixelSize()
- super().__init__(
- justify=justify_text,
- size=f'{str(font_size)}px'
- )
-
- # anchor to viewbox
- self.setParentItem(chart._vb)
- chart.scene().addItem(self)
- self.chart = chart
-
- v, h = anchor_at
- index = (_corner_anchors[h], _corner_anchors[v])
- margins = _corner_margins[(v, h)]
-
- ydim = margins[1]
- if inspect.isfunction(margins[1]):
- margins = margins[0], ydim(font_size)
-
- self.anchor(itemPos=index, parentPos=index, offset=margins)
-
- def update_from_ohlc(
- self,
- name: str,
- index: int,
- array: np.ndarray,
- ) -> None:
- # this being "html" is the dumbest shit :eyeroll:
- first = array[0]['index']
-
- self.setText(
- "i:{index}
"
- "O:{}
"
- "H:{}
"
- "L:{}
"
- "C:{}
"
- "V:{}
"
- "wap:{}".format(
- *array[index - first][
- ['open', 'high', 'low', 'close', 'volume', 'bar_wap']
- ],
- name=name,
- index=index,
- )
- )
-
- def update_from_value(
- self,
- name: str,
- index: int,
- array: np.ndarray,
- ) -> None:
- first = array[0]['index']
- if index < array[-1]['index'] and index > first:
- data = array[index - first][name]
- self.setText(f"{name}: {data:.2f}")
-
-
-class CrossHair(pg.GraphicsObject):
-
- def __init__(
- self,
- linkedsplitcharts: 'LinkedSplitCharts', # noqa
- digits: int = 0
- ) -> None:
- super().__init__()
- # XXX: not sure why these are instance variables?
- # It's not like we can change them on the fly..?
- self.pen = pg.mkPen(
- color=hcolor('default'),
- style=QtCore.Qt.DashLine,
- )
- self.lines_pen = pg.mkPen(
- color='#a9a9a9', # gray?
- style=QtCore.Qt.DashLine,
- )
- self.lsc = linkedsplitcharts
- self.graphics = {}
- self.plots = []
- self.active_plot = None
- self.digits = digits
- self._lastx = None
- # self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
-
- def add_plot(
- self,
- plot: 'ChartPlotWidget', # noqa
- digits: int = 0,
- ) -> None:
- # add ``pg.graphicsItems.InfiniteLine``s
- # vertical and horizonal lines and a y-axis label
- vl = plot.addLine(x=0, pen=self.lines_pen, movable=False)
- vl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
-
- hl = plot.addLine(y=0, pen=self.lines_pen, movable=False)
- hl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
- hl.hide()
-
- yl = YAxisLabel(
- parent=plot.getAxis('right'),
- digits=digits or self.digits,
- opacity=_ch_label_opac,
- bg_color='default',
- )
- yl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
- yl.hide() # on startup if mouse is off screen
-
- # TODO: checkout what ``.sigDelayed`` can be used for
- # (emitted once a sufficient delay occurs in mouse movement)
- px_moved = pg.SignalProxy(
- plot.scene().sigMouseMoved,
- rateLimit=_mouse_rate_limit,
- slot=self.mouseMoved,
- delay=_debounce_delay,
- )
- px_enter = pg.SignalProxy(
- plot.sig_mouse_enter,
- rateLimit=_mouse_rate_limit,
- slot=lambda: self.mouseAction('Enter', plot),
- delay=_debounce_delay,
- )
- px_leave = pg.SignalProxy(
- plot.sig_mouse_leave,
- rateLimit=_mouse_rate_limit,
- slot=lambda: self.mouseAction('Leave', plot),
- delay=_debounce_delay,
- )
- self.graphics[plot] = {
- 'vl': vl,
- 'hl': hl,
- 'yl': yl,
- 'px': (px_moved, px_enter, px_leave),
- }
- self.plots.append(plot)
-
- # Determine where to place x-axis label.
- # Place below the last plot by default, ow
- # keep x-axis right below main chart
- plot_index = -1 if _xaxis_at == 'bottom' else 0
-
- self.xaxis_label = XAxisLabel(
- parent=self.plots[plot_index].getAxis('bottom'),
- opacity=_ch_label_opac,
- bg_color='default',
- )
- # place label off-screen during startup
- self.xaxis_label.setPos(self.plots[0].mapFromView(QPointF(0, 0)))
- self.xaxis_label.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
-
- 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
- cursor = LineDot(curve, index=plot._ohlc[-1]['index'], plot=plot)
- plot.addItem(cursor)
- self.graphics[plot].setdefault('cursors', []).append(cursor)
- return cursor
-
- def mouseAction(self, action, plot): # noqa
- if action == 'Enter':
- self.active_plot = plot
-
- # show horiz line and y-label
- self.graphics[plot]['hl'].show()
- self.graphics[plot]['yl'].show()
-
- else: # Leave
- self.active_plot = None
-
- # hide horiz line and y-label
- self.graphics[plot]['hl'].hide()
- self.graphics[plot]['yl'].hide()
-
- def mouseMoved(
- self,
- evt: 'Tuple[QMouseEvent]', # noqa
- ) -> None: # noqa
- """Update horizonal and vertical lines when mouse moves inside
- either the main chart or any indicator subplot.
- """
- pos = evt[0]
-
- # find position inside active plot
- try:
- # map to view coordinate system
- mouse_point = self.active_plot.mapToView(pos)
- except AttributeError:
- # mouse was not on active plot
- return
-
- x, y = mouse_point.x(), mouse_point.y()
- plot = self.active_plot
-
- # update y-range items
- self.graphics[plot]['hl'].setY(y)
-
- self.graphics[self.active_plot]['yl'].update_label(
- abs_pos=pos, value=y
- )
-
- # Update x if cursor changed after discretization calc
- # (this saves draw cycles on small mouse moves)
- lastx = self._lastx
- ix = round(x) # since bars are centered around index
-
- if ix != lastx:
- for plot, opts in self.graphics.items():
-
- # move the vertical line to the current "center of bar"
- opts['vl'].setX(ix)
-
- # update the chart's "contents" label
- plot.update_contents_labels(ix)
-
- # update all subscribed curve dots
- # first = plot._ohlc[0]['index']
- for cursor in opts.get('cursors', ()):
- cursor.setIndex(ix)
-
- # update the label on the bottom of the crosshair
- self.xaxis_label.update_label(
-
- # XXX: requires:
- # https://github.com/pyqtgraph/pyqtgraph/pull/1418
- # otherwise gobbles tons of CPU..
-
- # map back to abs (label-local) coordinates
- abs_pos=plot.mapFromView(QPointF(ix, y)),
- value=x,
- )
-
- self._lastx = ix
-
- def boundingRect(self):
- try:
- return self.active_plot.boundingRect()
- except AttributeError:
- return self.plots[0].boundingRect()
-
-
-class LevelLabel(YSticky):
-
- line_pen = pg.mkPen(hcolor('bracket'))
-
- _w_margin = 4
- _h_margin = 3
- level: float = 0
-
- def __init__(
- self,
- chart,
- *args,
- orient_v: str = 'bottom',
- orient_h: str = 'left',
- **kwargs
- ) -> None:
- super().__init__(chart, *args, **kwargs)
-
- # orientation around axis options
- self._orient_v = orient_v
- self._orient_h = orient_h
- self._v_shift = {
- 'top': 1.,
- 'bottom': 0,
- 'middle': 1 / 2.
- }[orient_v]
-
- self._h_shift = {
- 'left': -1., 'right': 0
- }[orient_h]
-
- def update_label(
- self,
- abs_pos: QPointF, # scene coords
- level: float, # data for text
- offset: int = 1 # if have margins, k?
- ) -> None:
-
- # write contents, type specific
- self.set_label_str(level)
-
- br = self.boundingRect()
- h, w = br.height(), br.width()
-
- # this triggers ``.pain()`` implicitly?
- self.setPos(QPointF(
- self._h_shift * w - offset,
- abs_pos.y() - (self._v_shift * h) - offset
- ))
- self.update()
-
- self.level = level
-
- def set_label_str(self, level: float):
- # this is read inside ``.paint()``
- # self.label_str = '{size} x {level:.{digits}f}'.format(
- self.label_str = '{level:.{digits}f}'.format(
- # size=self._size,
- digits=self.digits,
- level=level
- ).replace(',', ' ')
-
- def size_hint(self) -> Tuple[None, None]:
- return None, None
-
- def draw(
- self,
- p: QtGui.QPainter,
- rect: QtCore.QRectF
- ) -> None:
- p.setPen(self.line_pen)
-
- if self._orient_v == 'bottom':
- lp, rp = rect.topLeft(), rect.topRight()
- # p.drawLine(rect.topLeft(), rect.topRight())
- elif self._orient_v == 'top':
- lp, rp = rect.bottomLeft(), rect.bottomRight()
-
- p.drawLine(lp.x(), lp.y(), rp.x(), rp.y())
-
-
-class L1Label(LevelLabel):
-
- size: float = 0
- size_digits: float = 3
-
- text_flags = (
- QtCore.Qt.TextDontClip
- | QtCore.Qt.AlignLeft
- )
-
- def set_label_str(self, level: float) -> None:
- """Reimplement the label string write to include the level's order-queue's
- size in the text, eg. 100 x 323.3.
-
- """
- self.label_str = '{size:.{size_digits}f} x {level:,.{digits}f}'.format(
- size_digits=self.size_digits,
- size=self.size or '?',
- digits=self.digits,
- level=level
- ).replace(',', ' ')
-
-
-class L1Labels:
- """Level 1 bid ask labels for dynamic update on price-axis.
-
- """
- max_value: float = '100.0 x 100 000.00'
-
- def __init__(
- self,
- chart: 'ChartPlotWidget', # noqa
- digits: int = 2,
- size_digits: int = 0,
- font_size_inches: float = _down_2_font_inches_we_like,
- ) -> None:
-
- self.chart = chart
-
- self.bid_label = L1Label(
- chart=chart,
- parent=chart.getAxis('right'),
- # TODO: pass this from symbol data
- digits=digits,
- opacity=1,
- font_size_inches=font_size_inches,
- bg_color='papas_special',
- fg_color='bracket',
- orient_v='bottom',
- )
- self.bid_label.size_digits = size_digits
- self.bid_label._size_br_from_str(self.max_value)
-
- self.ask_label = L1Label(
- chart=chart,
- parent=chart.getAxis('right'),
- # TODO: pass this from symbol data
- digits=digits,
- opacity=1,
- font_size_inches=font_size_inches,
- bg_color='papas_special',
- fg_color='bracket',
- orient_v='top',
- )
- self.ask_label.size_digits = size_digits
- self.ask_label._size_br_from_str(self.max_value)
-
-
-class LevelLine(pg.InfiniteLine):
- def __init__(
- self,
- label: LevelLabel,
- **kwargs,
- ) -> None:
- self.label = label
- super().__init__(**kwargs)
- self.sigPositionChanged.connect(self.set_level)
-
- def set_level(self, value: float) -> None:
- self.label.update_from_data(0, self.value())
-
-
-def level_line(
- chart: 'ChartPlogWidget', # noqa
- level: float,
- digits: int = 1,
-
- # size 4 font on 4k screen scaled down, so small-ish.
- font_size_inches: float = _down_2_font_inches_we_like,
-
- show_label: bool = True,
-
- **linelabelkwargs
-) -> LevelLine:
- """Convenience routine to add a styled horizontal line to a plot.
-
- """
- label = LevelLabel(
- chart=chart,
- parent=chart.getAxis('right'),
- # TODO: pass this from symbol data
- digits=digits,
- opacity=1,
- font_size_inches=font_size_inches,
- # TODO: make this take the view's bg pen
- bg_color='papas_special',
- fg_color='default',
- **linelabelkwargs
- )
- label.update_from_data(0, level)
-
- # TODO: can we somehow figure out a max value from the parent axis?
- label._size_br_from_str(label.label_str)
-
- line = LevelLine(
- label,
- movable=True,
- angle=0,
- )
- line.setValue(level)
- line.setPen(pg.mkPen(hcolor('default')))
- # activate/draw label
- line.setValue(level)
-
- chart.plotItem.addItem(line)
-
- if not show_label:
- label.hide()
-
- return line
diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py
new file mode 100644
index 00000000..2e6c2c35
--- /dev/null
+++ b/piker/ui/_graphics/_cursor.py
@@ -0,0 +1,380 @@
+# piker: trading gear for hackers
+# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
+
+# 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 .
+"""
+Mouse interaction graphics
+
+"""
+from typing import Optional, Tuple
+
+import inspect
+import numpy as np
+import pyqtgraph as pg
+from PyQt5 import QtCore, QtGui
+from PyQt5.QtCore import QPointF
+
+from .._style import (
+ _xaxis_at,
+ hcolor,
+ _font,
+)
+from .._axes import YAxisLabel, XAxisLabel
+
+# XXX: these settings seem to result in really decent mouse scroll
+# 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 = 60 # TODO; should we calc current screen refresh rate?
+_debounce_delay = 1 / 2e3
+_ch_label_opac = 1
+
+
+# TODO: we need to handle the case where index is outside
+# the underlying datums range
+class LineDot(pg.CurvePoint):
+
+ def __init__(
+ self,
+ curve: pg.PlotCurveItem,
+ index: int,
+ plot: 'ChartPlotWidget', # type: ingore # noqa
+ pos=None,
+ size: int = 2, # in pxs
+ color: str = 'default_light',
+ ) -> None:
+ pg.CurvePoint.__init__(
+ self,
+ curve,
+ index=index,
+ pos=pos,
+ rotate=False,
+ )
+ self._plot = plot
+
+ # TODO: get pen from curve if not defined?
+ cdefault = hcolor(color)
+ pen = pg.mkPen(cdefault)
+ brush = pg.mkBrush(cdefault)
+
+ # presuming this is fast since it's built in?
+ dot = self.dot = QtGui.QGraphicsEllipseItem(
+ QtCore.QRectF(-size / 2, -size / 2, size, size)
+ )
+ # if we needed transformable dot?
+ # dot.translate(-size*0.5, -size*0.5)
+ dot.setPen(pen)
+ dot.setBrush(brush)
+ dot.setParentItem(self)
+
+ # keep a static size
+ self.setFlag(self.ItemIgnoresTransformations)
+
+ def event(
+ self,
+ ev: QtCore.QEvent,
+ ) -> None:
+ # print((ev, type(ev)))
+ if not isinstance(
+ ev, QtCore.QDynamicPropertyChangeEvent
+ ) or self.curve() is None:
+ return False
+
+ # if ev.propertyName() == 'index':
+ # print(ev)
+ # # self.setProperty
+
+ (x, y) = self.curve().getData()
+ index = self.property('index')
+ # first = self._plot._ohlc[0]['index']
+ # first = x[0]
+ # i = index - first
+ i = index - x[0]
+ if i > 0 and i < len(y):
+ newPos = (index, y[i])
+ QtGui.QGraphicsItem.setPos(self, *newPos)
+ return True
+
+ return False
+
+
+_corner_anchors = {
+ 'top': 0,
+ 'left': 0,
+ 'bottom': 1,
+ 'right': 1,
+}
+# XXX: fyi naming here is confusing / opposite to coords
+_corner_margins = {
+ ('top', 'left'): (-4, -5),
+ ('top', 'right'): (4, -5),
+
+ ('bottom', 'left'): (-4, lambda font_size: font_size * 2),
+ ('bottom', 'right'): (4, lambda font_size: font_size * 2),
+}
+
+
+class ContentsLabel(pg.LabelItem):
+ """Label anchored to a ``ViewBox`` typically for displaying
+ datum-wise points from the "viewed" contents.
+
+ """
+ def __init__(
+ self,
+ chart: 'ChartPlotWidget', # noqa
+ anchor_at: str = ('top', 'right'),
+ justify_text: str = 'left',
+ font_size: Optional[int] = None,
+ ) -> None:
+ font_size = font_size or _font.font.pixelSize()
+ super().__init__(
+ justify=justify_text,
+ size=f'{str(font_size)}px'
+ )
+
+ # anchor to viewbox
+ self.setParentItem(chart._vb)
+ chart.scene().addItem(self)
+ self.chart = chart
+
+ v, h = anchor_at
+ index = (_corner_anchors[h], _corner_anchors[v])
+ margins = _corner_margins[(v, h)]
+
+ ydim = margins[1]
+ if inspect.isfunction(margins[1]):
+ margins = margins[0], ydim(font_size)
+
+ self.anchor(itemPos=index, parentPos=index, offset=margins)
+
+ def update_from_ohlc(
+ self,
+ name: str,
+ index: int,
+ array: np.ndarray,
+ ) -> None:
+ # this being "html" is the dumbest shit :eyeroll:
+ first = array[0]['index']
+
+ self.setText(
+ "i:{index}
"
+ "O:{}
"
+ "H:{}
"
+ "L:{}
"
+ "C:{}
"
+ "V:{}
"
+ "wap:{}".format(
+ *array[index - first][
+ ['open', 'high', 'low', 'close', 'volume', 'bar_wap']
+ ],
+ name=name,
+ index=index,
+ )
+ )
+
+ def update_from_value(
+ self,
+ name: str,
+ index: int,
+ array: np.ndarray,
+ ) -> None:
+ first = array[0]['index']
+ if index < array[-1]['index'] and index > first:
+ data = array[index - first][name]
+ self.setText(f"{name}: {data:.2f}")
+
+
+class CrossHair(pg.GraphicsObject):
+
+ def __init__(
+ self,
+ linkedsplitcharts: 'LinkedSplitCharts', # noqa
+ digits: int = 0
+ ) -> None:
+ super().__init__()
+ # XXX: not sure why these are instance variables?
+ # It's not like we can change them on the fly..?
+ self.pen = pg.mkPen(
+ color=hcolor('default'),
+ style=QtCore.Qt.DashLine,
+ )
+ self.lines_pen = pg.mkPen(
+ color='#a9a9a9', # gray?
+ style=QtCore.Qt.DashLine,
+ )
+ self.lsc = linkedsplitcharts
+ self.graphics = {}
+ self.plots = []
+ self.active_plot = None
+ self.digits = digits
+ self._lastx = None
+ # self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
+
+ def add_plot(
+ self,
+ plot: 'ChartPlotWidget', # noqa
+ digits: int = 0,
+ ) -> None:
+ # add ``pg.graphicsItems.InfiniteLine``s
+ # vertical and horizonal lines and a y-axis label
+ vl = plot.addLine(x=0, pen=self.lines_pen, movable=False)
+ vl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
+
+ hl = plot.addLine(y=0, pen=self.lines_pen, movable=False)
+ hl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
+ hl.hide()
+
+ yl = YAxisLabel(
+ parent=plot.getAxis('right'),
+ digits=digits or self.digits,
+ opacity=_ch_label_opac,
+ bg_color='default',
+ )
+ yl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
+ yl.hide() # on startup if mouse is off screen
+
+ # TODO: checkout what ``.sigDelayed`` can be used for
+ # (emitted once a sufficient delay occurs in mouse movement)
+ px_moved = pg.SignalProxy(
+ plot.scene().sigMouseMoved,
+ rateLimit=_mouse_rate_limit,
+ slot=self.mouseMoved,
+ delay=_debounce_delay,
+ )
+ px_enter = pg.SignalProxy(
+ plot.sig_mouse_enter,
+ rateLimit=_mouse_rate_limit,
+ slot=lambda: self.mouseAction('Enter', plot),
+ delay=_debounce_delay,
+ )
+ px_leave = pg.SignalProxy(
+ plot.sig_mouse_leave,
+ rateLimit=_mouse_rate_limit,
+ slot=lambda: self.mouseAction('Leave', plot),
+ delay=_debounce_delay,
+ )
+ self.graphics[plot] = {
+ 'vl': vl,
+ 'hl': hl,
+ 'yl': yl,
+ 'px': (px_moved, px_enter, px_leave),
+ }
+ self.plots.append(plot)
+
+ # Determine where to place x-axis label.
+ # Place below the last plot by default, ow
+ # keep x-axis right below main chart
+ plot_index = -1 if _xaxis_at == 'bottom' else 0
+
+ self.xaxis_label = XAxisLabel(
+ parent=self.plots[plot_index].getAxis('bottom'),
+ opacity=_ch_label_opac,
+ bg_color='default',
+ )
+ # place label off-screen during startup
+ self.xaxis_label.setPos(self.plots[0].mapFromView(QPointF(0, 0)))
+ self.xaxis_label.setCacheMode(
+ QtGui.QGraphicsItem.DeviceCoordinateCache)
+
+ 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
+ cursor = LineDot(curve, index=plot._ohlc[-1]['index'], plot=plot)
+ plot.addItem(cursor)
+ self.graphics[plot].setdefault('cursors', []).append(cursor)
+ return cursor
+
+ def mouseAction(self, action, plot): # noqa
+ if action == 'Enter':
+ self.active_plot = plot
+
+ # show horiz line and y-label
+ self.graphics[plot]['hl'].show()
+ self.graphics[plot]['yl'].show()
+
+ else: # Leave
+ self.active_plot = None
+
+ # hide horiz line and y-label
+ self.graphics[plot]['hl'].hide()
+ self.graphics[plot]['yl'].hide()
+
+ def mouseMoved(
+ self,
+ evt: 'Tuple[QMouseEvent]', # noqa
+ ) -> None: # noqa
+ """Update horizonal and vertical lines when mouse moves inside
+ either the main chart or any indicator subplot.
+ """
+ pos = evt[0]
+
+ # find position inside active plot
+ try:
+ # map to view coordinate system
+ mouse_point = self.active_plot.mapToView(pos)
+ except AttributeError:
+ # mouse was not on active plot
+ return
+
+ x, y = mouse_point.x(), mouse_point.y()
+ plot = self.active_plot
+
+ # update y-range items
+ self.graphics[plot]['hl'].setY(y)
+
+ self.graphics[self.active_plot]['yl'].update_label(
+ abs_pos=pos, value=y
+ )
+
+ # Update x if cursor changed after discretization calc
+ # (this saves draw cycles on small mouse moves)
+ lastx = self._lastx
+ ix = round(x) # since bars are centered around index
+
+ if ix != lastx:
+ for plot, opts in self.graphics.items():
+
+ # move the vertical line to the current "center of bar"
+ opts['vl'].setX(ix)
+
+ # update the chart's "contents" label
+ plot.update_contents_labels(ix)
+
+ # update all subscribed curve dots
+ # first = plot._ohlc[0]['index']
+ for cursor in opts.get('cursors', ()):
+ cursor.setIndex(ix)
+
+ # update the label on the bottom of the crosshair
+ self.xaxis_label.update_label(
+
+ # XXX: requires:
+ # https://github.com/pyqtgraph/pyqtgraph/pull/1418
+ # otherwise gobbles tons of CPU..
+
+ # map back to abs (label-local) coordinates
+ abs_pos=plot.mapFromView(QPointF(ix, y)),
+ value=x,
+ )
+
+ self._lastx = ix
+
+ def boundingRect(self):
+ try:
+ return self.active_plot.boundingRect()
+ except AttributeError:
+ return self.plots[0].boundingRect()
diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py
new file mode 100644
index 00000000..bd5b9de6
--- /dev/null
+++ b/piker/ui/_graphics/_lines.py
@@ -0,0 +1,244 @@
+# piker: trading gear for hackers
+# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
+
+# 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 .
+
+"""
+Lines for orders, alerts, L2.
+
+"""
+from typing import Tuple
+
+import pyqtgraph as pg
+from PyQt5 import QtCore, QtGui
+from PyQt5.QtCore import QPointF
+
+from .._style import (
+ hcolor,
+ _down_2_font_inches_we_like,
+)
+from .._axes import YSticky
+
+
+class LevelLabel(YSticky):
+
+ line_pen = pg.mkPen(hcolor('bracket'))
+
+ _w_margin = 4
+ _h_margin = 3
+ level: float = 0
+
+ def __init__(
+ self,
+ chart,
+ *args,
+ orient_v: str = 'bottom',
+ orient_h: str = 'left',
+ **kwargs
+ ) -> None:
+ super().__init__(chart, *args, **kwargs)
+
+ # orientation around axis options
+ self._orient_v = orient_v
+ self._orient_h = orient_h
+ self._v_shift = {
+ 'top': 1.,
+ 'bottom': 0,
+ 'middle': 1 / 2.
+ }[orient_v]
+
+ self._h_shift = {
+ 'left': -1., 'right': 0
+ }[orient_h]
+
+ def update_label(
+ self,
+ abs_pos: QPointF, # scene coords
+ level: float, # data for text
+ offset: int = 1 # if have margins, k?
+ ) -> None:
+
+ # write contents, type specific
+ self.set_label_str(level)
+
+ br = self.boundingRect()
+ h, w = br.height(), br.width()
+
+ # this triggers ``.pain()`` implicitly?
+ self.setPos(QPointF(
+ self._h_shift * w - offset,
+ abs_pos.y() - (self._v_shift * h) - offset
+ ))
+ self.update()
+
+ self.level = level
+
+ def set_label_str(self, level: float):
+ # this is read inside ``.paint()``
+ # self.label_str = '{size} x {level:.{digits}f}'.format(
+ self.label_str = '{level:.{digits}f}'.format(
+ # size=self._size,
+ digits=self.digits,
+ level=level
+ ).replace(',', ' ')
+
+ def size_hint(self) -> Tuple[None, None]:
+ return None, None
+
+ def draw(
+ self,
+ p: QtGui.QPainter,
+ rect: QtCore.QRectF
+ ) -> None:
+ p.setPen(self.line_pen)
+
+ if self._orient_v == 'bottom':
+ lp, rp = rect.topLeft(), rect.topRight()
+ # p.drawLine(rect.topLeft(), rect.topRight())
+ elif self._orient_v == 'top':
+ lp, rp = rect.bottomLeft(), rect.bottomRight()
+
+ p.drawLine(lp.x(), lp.y(), rp.x(), rp.y())
+
+
+class L1Label(LevelLabel):
+
+ size: float = 0
+ size_digits: float = 3
+
+ text_flags = (
+ QtCore.Qt.TextDontClip
+ | QtCore.Qt.AlignLeft
+ )
+
+ def set_label_str(self, level: float) -> None:
+ """Reimplement the label string write to include the level's order-queue's
+ size in the text, eg. 100 x 323.3.
+
+ """
+ self.label_str = '{size:.{size_digits}f} x {level:,.{digits}f}'.format(
+ size_digits=self.size_digits,
+ size=self.size or '?',
+ digits=self.digits,
+ level=level
+ ).replace(',', ' ')
+
+
+class L1Labels:
+ """Level 1 bid ask labels for dynamic update on price-axis.
+
+ """
+ max_value: float = '100.0 x 100 000.00'
+
+ def __init__(
+ self,
+ chart: 'ChartPlotWidget', # noqa
+ digits: int = 2,
+ size_digits: int = 0,
+ font_size_inches: float = _down_2_font_inches_we_like,
+ ) -> None:
+
+ self.chart = chart
+
+ self.bid_label = L1Label(
+ chart=chart,
+ parent=chart.getAxis('right'),
+ # TODO: pass this from symbol data
+ digits=digits,
+ opacity=1,
+ font_size_inches=font_size_inches,
+ bg_color='papas_special',
+ fg_color='bracket',
+ orient_v='bottom',
+ )
+ self.bid_label.size_digits = size_digits
+ self.bid_label._size_br_from_str(self.max_value)
+
+ self.ask_label = L1Label(
+ chart=chart,
+ parent=chart.getAxis('right'),
+ # TODO: pass this from symbol data
+ digits=digits,
+ opacity=1,
+ font_size_inches=font_size_inches,
+ bg_color='papas_special',
+ fg_color='bracket',
+ orient_v='top',
+ )
+ self.ask_label.size_digits = size_digits
+ self.ask_label._size_br_from_str(self.max_value)
+
+
+class LevelLine(pg.InfiniteLine):
+ def __init__(
+ self,
+ label: LevelLabel,
+ **kwargs,
+ ) -> None:
+ self.label = label
+ super().__init__(**kwargs)
+ self.sigPositionChanged.connect(self.set_level)
+
+ def set_level(self, value: float) -> None:
+ self.label.update_from_data(0, self.value())
+
+
+def level_line(
+ chart: 'ChartPlogWidget', # noqa
+ level: float,
+ digits: int = 1,
+
+ # size 4 font on 4k screen scaled down, so small-ish.
+ font_size_inches: float = _down_2_font_inches_we_like,
+
+ show_label: bool = True,
+
+ **linelabelkwargs
+) -> LevelLine:
+ """Convenience routine to add a styled horizontal line to a plot.
+
+ """
+ label = LevelLabel(
+ chart=chart,
+ parent=chart.getAxis('right'),
+ # TODO: pass this from symbol data
+ digits=digits,
+ opacity=1,
+ font_size_inches=font_size_inches,
+ # TODO: make this take the view's bg pen
+ bg_color='papas_special',
+ fg_color='default',
+ **linelabelkwargs
+ )
+ label.update_from_data(0, level)
+
+ # TODO: can we somehow figure out a max value from the parent axis?
+ label._size_br_from_str(label.label_str)
+
+ line = LevelLine(
+ label,
+ movable=True,
+ angle=0,
+ )
+ line.setValue(level)
+ line.setPen(pg.mkPen(hcolor('default')))
+ # activate/draw label
+ line.setValue(level)
+
+ chart.plotItem.addItem(line)
+
+ if not show_label:
+ label.hide()
+
+ return line
diff --git a/piker/ui/_graphics/_ohlc.py b/piker/ui/_graphics/_ohlc.py
index 1c505005..d3fcb59d 100644
--- a/piker/ui/_graphics/_ohlc.py
+++ b/piker/ui/_graphics/_ohlc.py
@@ -15,15 +15,18 @@
# along with this program. If not, see .
"""
Super fast OHLC sampling graphics types.
+
"""
from typing import List, Optional, Tuple
import numpy as np
import pyqtgraph as pg
from numba import jit, float64, int64 # , optional
-# from numba import types as ntypes
from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QLineF, QPointF
+# from numba import types as ntypes
+# from .._profile import timeit
+# from ..data._source import numba_ohlc_dtype
from .._style import hcolor