piker/piker/ui/_axes.py

465 lines
12 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 piker0)
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 axes graphics and behavior.
2021-02-11 16:48:19 +00:00
"""
2020-11-06 17:23:14 +00:00
2020-11-02 17:02:05 +00:00
from typing import List, Tuple, Optional
2020-08-31 21:18:02 +00:00
import pandas as pd
import pyqtgraph as pg
2020-12-29 19:34:25 +00:00
from PyQt5 import QtCore, QtGui, QtWidgets
2020-08-30 16:27:41 +00:00
from PyQt5.QtCore import QPointF
2020-11-02 17:02:05 +00:00
from ._style import DpiAwareFont, hcolor, _font
from ..data._source import float_digits
_axis_pen = pg.mkPen(hcolor('bracket'))
class Axis(pg.AxisItem):
2021-02-11 16:48:19 +00:00
"""A better axis that sizes tick contents considering font size.
2020-11-02 17:02:05 +00:00
"""
def __init__(
self,
linked_charts,
2020-12-09 13:53:09 +00:00
typical_max_str: str = '100 000.000',
2020-11-06 20:24:01 +00:00
min_tick: int = 2,
**kwargs
) -> None:
super().__init__(**kwargs)
2020-12-29 19:34:25 +00:00
# XXX: pretty sure this makes things slower
# self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
2020-11-02 20:43:19 +00:00
self.linked_charts = linked_charts
2020-11-06 20:24:01 +00:00
self._min_tick = min_tick
2020-11-02 17:02:05 +00:00
self.setTickFont(_font.font)
self.setStyle(**{
2021-02-21 16:44:58 +00:00
'textFillLimits': [(0, 0.5)],
2020-11-02 17:02:05 +00:00
'tickFont': _font.font,
2020-11-16 22:39:14 +00:00
# offset of text *away from* axis line in px
'tickTextOffset': 6,
})
2020-11-02 17:02:05 +00:00
self.setTickFont(_font.font)
self.setPen(_axis_pen)
2020-11-02 17:02:05 +00:00
self.typical_br = _font._qfm.boundingRect(typical_max_str)
# size the pertinent axis dimension to a "typical value"
2021-02-11 16:48:19 +00:00
self.size_to_values()
def size_to_values(self) -> None:
pass
2020-11-06 20:24:01 +00:00
def set_min_tick(self, size: int) -> None:
self._min_tick = size
2021-02-11 16:48:19 +00:00
def txt_offsets(self) -> Tuple[int, int]:
return tuple(self.style['tickTextOffset'])
class PriceAxis(Axis):
def __init__(
self,
*args,
**kwargs,
) -> None:
2021-02-11 16:48:19 +00:00
super().__init__(*args, **kwargs)
self.setStyle(**{
# offset of text *away from* axis line in px
'tickTextOffset': 9,
})
2021-02-11 16:48:19 +00:00
def size_to_values(self) -> None:
self.setWidth(self.typical_br.width())
# XXX: drop for now since it just eats up h space
def tickStrings(self, vals, scale, spacing):
2020-11-06 20:24:01 +00:00
# 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 [
2020-11-16 22:39:14 +00:00
('{value:,.{digits}f}').format(
digits=digits,
value=v,
).replace(',', ' ') for v in vals
]
class DynamicDateAxis(Axis):
2020-08-31 21:18:02 +00:00
# time formats mapped by seconds between bars
tick_tpl = {
60 * 60 * 24: '%Y-%b-%d',
2020-08-31 21:18:02 +00:00
60: '%H:%M',
30: '%H:%M:%S',
5: '%H:%M:%S',
2020-11-16 22:39:14 +00:00
1: '%H:%M:%S',
2020-08-31 21:18:02 +00:00
}
2021-02-11 16:48:19 +00:00
def size_to_values(self) -> None:
2020-11-16 22:39:14 +00:00
self.setHeight(self.typical_br.height() + 1)
2020-08-31 21:18:02 +00:00
def _indexes_to_timestrs(
self,
indexes: List[int],
) -> List[str]:
2020-11-02 17:02:05 +00:00
2020-12-14 17:22:05 +00:00
# try:
chart = self.linked_charts.chart
bars = chart._ohlc
shm = self.linked_charts.chart._shm
first = shm._first.value
2020-08-31 21:18:02 +00:00
bars_len = len(bars)
2020-11-02 17:02:05 +00:00
times = bars['time']
2020-08-31 21:18:02 +00:00
epochs = times[list(
2020-12-14 17:22:05 +00:00
map(
int,
filter(
lambda i: i > 0 and i < bars_len,
(i-first for i in indexes)
)
)
2020-08-31 21:18:02 +00:00
)]
2020-12-14 17:22:05 +00:00
2020-08-31 21:18:02 +00:00
# TODO: **don't** have this hard coded shift to EST
dts = pd.to_datetime(epochs, unit='s') # - 4*pd.offsets.Hour()
2020-11-02 17:02:05 +00:00
delay = times[-1] - times[-2]
2020-08-31 21:18:02 +00:00
return dts.strftime(self.tick_tpl[delay])
2020-08-31 21:18:02 +00:00
def tickStrings(self, values: List[float], scale, spacing):
return self._indexes_to_timestrs(values)
class AxisLabel(pg.GraphicsObject):
_x_margin = 0
_y_margin = 0
def __init__(
self,
2021-02-11 16:48:19 +00:00
parent: pg.GraphicsItem,
digits: int = 2,
2021-02-11 16:48:19 +00:00
bg_color: str = 'bracket',
fg_color: str = 'black',
2021-02-11 16:48:19 +00:00
opacity: int = 1, # XXX: seriously don't set this to 0
font_size: str = 'default',
2021-02-11 16:48:19 +00:00
use_arrow: bool = True,
2021-02-11 16:48:19 +00:00
2020-12-29 19:34:25 +00:00
) -> None:
2021-02-11 16:48:19 +00:00
super().__init__()
self.setParentItem(parent)
2020-11-02 20:43:19 +00:00
self.setFlag(self.ItemIgnoresTransformations)
2020-12-29 19:34:25 +00:00
# XXX: pretty sure this is faster
self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
2020-11-02 20:43:19 +00:00
2021-02-11 16:48:19 +00:00
self._parent = parent
self.opacity = opacity
self.label_str = ''
self.digits = digits
2020-11-02 17:02:05 +00:00
self._txt_br: QtCore.QRect = None
self._dpifont = DpiAwareFont(font_size=font_size)
self._dpifont.configure_to_dpi()
self.bg_color = pg.mkColor(hcolor(bg_color))
self.fg_color = pg.mkColor(hcolor(fg_color))
self._use_arrow = use_arrow
# create triangle path
self.path = None
self.rect = None
2020-12-29 19:34:25 +00:00
def paint(
self,
p: QtGui.QPainter,
opt: QtWidgets.QStyleOptionGraphicsItem,
w: QtWidgets.QWidget
) -> None:
2021-02-11 16:48:19 +00:00
"""Draw a filled rectangle based on the size of ``.label_str`` text.
Subtypes can customize further by overloading ``.draw()``.
"""
# p.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver)
if self.label_str:
# if not self.rect:
self._size_br_from_str(self.label_str)
# can be overrided in subtype
self.draw(p, self.rect)
2020-08-31 21:18:02 +00:00
2021-02-11 16:48:19 +00:00
p.setFont(self._dpifont.font)
p.setPen(self.fg_color)
2020-11-02 17:02:05 +00:00
p.drawText(self.rect, self.text_flags, self.label_str)
2020-08-30 16:27:41 +00:00
2021-02-11 16:48:19 +00:00
def draw(
self,
p: QtGui.QPainter,
rect: QtCore.QRectF
) -> None:
if self._use_arrow:
if not self.path:
self._draw_arrow_path()
p.drawPath(self.path)
p.fillPath(self.path, pg.mkBrush(self.bg_color))
2021-02-11 16:48:19 +00:00
# this adds a nice black outline around the label for some odd
# reason; ok by us
p.setOpacity(self.opacity)
# this cause the L1 labels to glitch out if used
# in the subtype and it will leave a small black strip
# with the arrow path if done before the above
p.fillRect(self.rect, self.bg_color)
2020-08-30 16:27:41 +00:00
def boundingRect(self): # noqa
2021-02-11 16:48:19 +00:00
"""Size the graphics space from the text contents.
"""
if self.label_str:
self._size_br_from_str(self.label_str)
# if self.path:
# self.tl = self.path.controlPointRect().topLeft()
if not self.path:
self.tl = self.rect.topLeft()
return QtCore.QRectF(
self.tl,
self.rect.bottomRight(),
)
2020-11-02 17:02:05 +00:00
return QtCore.QRectF()
2020-11-02 17:02:05 +00:00
# return self.rect or QtCore.QRectF()
def _size_br_from_str(self, value: str) -> None:
"""Do our best to render the bounding rect to a set margin
around provided string contents.
2020-08-30 16:27:41 +00:00
"""
2020-11-02 17:02:05 +00:00
# size the filled rect to text and/or parent axis
# if not self._txt_br:
# # XXX: this can't be c
# self._txt_br = self._dpifont.boundingRect(value)
2020-11-02 17:02:05 +00:00
txt_br = self._txt_br = self._dpifont.boundingRect(value)
txt_h, txt_w = txt_br.height(), txt_br.width()
2021-02-11 16:48:19 +00:00
# allow subtypes to specify a static width and height
h, w = self.size_hint()
2020-11-02 17:02:05 +00:00
self.rect = QtCore.QRectF(
0, 0,
2021-02-11 16:48:19 +00:00
(w or txt_w) + self._x_margin /2,
(h or txt_h) + self._y_margin /2,
)
# print(self.rect)
# hb = self.path.controlPointRect()
# hb_size = hb.size()
2020-08-30 16:27:41 +00:00
return self.rect
2020-08-30 16:27:41 +00:00
# _common_text_flags = (
# QtCore.Qt.TextDontClip |
# QtCore.Qt.AlignCenter |
# QtCore.Qt.AlignTop |
# QtCore.Qt.AlignHCenter |
# QtCore.Qt.AlignVCenter
# )
class XAxisLabel(AxisLabel):
_x_margin = 8
text_flags = (
2020-08-30 16:27:41 +00:00
QtCore.Qt.TextDontClip
| QtCore.Qt.AlignCenter
)
def size_hint(self) -> Tuple[float, float]:
# size to parent axis height
2021-02-11 16:48:19 +00:00
return self._parent.height(), None
2020-08-31 21:18:02 +00:00
def update_label(
self,
abs_pos: QPointF, # scene coords
value: float, # data for text
offset: int = 0 # if have margins, k?
2020-08-31 21:18:02 +00:00
) -> None:
2020-11-02 17:02:05 +00:00
2021-02-11 16:48:19 +00:00
timestrs = self._parent._indexes_to_timestrs([int(value)])
2020-09-09 14:47:08 +00:00
if not timestrs.any():
return
pad = 1*' '
self.label_str = pad + timestrs[0] + pad
2021-02-11 16:48:19 +00:00
_, y_offset = self._parent.txt_offsets()
2020-11-02 17:02:05 +00:00
w = self.boundingRect().width()
2021-02-11 16:48:19 +00:00
2020-11-02 17:02:05 +00:00
self.setPos(QPointF(
abs_pos.x() - w/2,
y_offset/2,
2020-11-02 17:02:05 +00:00
))
self.update()
def _draw_arrow_path(self):
2021-02-11 16:48:19 +00:00
y_offset = self._parent.style['tickTextOffset'][1]
path = QtGui.QPainterPath()
h, w = self.rect.height(), self.rect.width()
middle = w/2 - 0.5
aw = h/2
left = middle - aw
right = middle + aw
path.moveTo(left, 0)
path.lineTo(middle, -y_offset)
path.lineTo(right, 0)
path.closeSubpath()
self.path = path
2021-02-11 16:48:19 +00:00
# top left point is local origin and tip of the arrow path
self.tl = QtCore.QPointF(0, -y_offset)
class YAxisLabel(AxisLabel):
_y_margin = 4
text_flags = (
2020-12-09 13:53:09 +00:00
QtCore.Qt.AlignLeft
# QtCore.Qt.AlignHCenter
2020-11-06 20:24:01 +00:00
| QtCore.Qt.AlignVCenter
2020-08-30 16:27:41 +00:00
| QtCore.Qt.TextDontClip
)
def __init__(
self,
chart,
*args,
**kwargs
) -> None:
super().__init__(*args, **kwargs)
self._chart = chart
2021-02-11 16:48:19 +00:00
chart.sigRangeChanged.connect(self.update_on_resize)
2021-02-11 16:48:19 +00:00
self._last_datum = (None, None)
# pull text offset from axis from parent axis
2021-02-11 16:48:19 +00:00
if getattr(self._parent, 'txt_offsets', False):
self.x_offset, y_offset = self._parent.txt_offsets()
def size_hint(self) -> Tuple[float, float]:
# size to parent axis width
2021-02-11 16:48:19 +00:00
return None, self._parent.width()
2020-08-30 16:27:41 +00:00
def update_label(
self,
abs_pos: QPointF, # scene coords
value: float, # data for text
# on odd dimension and/or adds nice black line
x_offset: Optional[int] = None
2020-08-30 16:27:41 +00:00
) -> None:
2020-11-02 17:02:05 +00:00
# this is read inside ``.paint()``
self.label_str = '{value:,.{digits}f}'.format(
digits=self.digits, value=value).replace(',', ' ')
2020-11-02 17:02:05 +00:00
# pull text offset from axis from parent axis
x_offset = x_offset or self.x_offset
2020-11-02 17:02:05 +00:00
br = self.boundingRect()
h = br.height()
2021-02-11 16:48:19 +00:00
2020-11-02 17:02:05 +00:00
self.setPos(QPointF(
x_offset,
abs_pos.y() - h / 2 - self._y_margin / 2
2020-11-02 17:02:05 +00:00
))
self.update()
2020-08-30 16:27:41 +00:00
def update_on_resize(self, vr, r):
"""Tiis is a ``.sigRangeChanged()`` handler.
"""
2020-11-02 17:02:05 +00:00
index, last = self._last_datum
if index is not None:
self.update_from_data(index, last)
2020-11-02 17:02:05 +00:00
2020-08-30 16:27:41 +00:00
def update_from_data(
self,
index: int,
value: float,
2021-03-14 02:06:49 +00:00
_save_last: bool = True,
2020-08-30 16:27:41 +00:00
) -> None:
2021-02-11 16:48:19 +00:00
"""Update the label's text contents **and** position from
a view box coordinate datum.
"""
2021-03-14 02:06:49 +00:00
if _save_last:
self._last_datum = (index, value)
2020-08-30 16:27:41 +00:00
self.update_label(
self._chart.mapFromView(QPointF(index, value)),
value
2020-08-30 16:27:41 +00:00
)
def _draw_arrow_path(self):
2021-02-11 16:48:19 +00:00
x_offset = self._parent.style['tickTextOffset'][0]
path = QtGui.QPainterPath()
h = self.rect.height()
path.moveTo(0, 0)
2021-03-13 22:28:57 +00:00
path.lineTo(-x_offset - 4, h/2.)
path.lineTo(0, h)
path.closeSubpath()
self.path = path
self.tl = path.controlPointRect().topLeft()