137 lines
5.0 KiB
Python
137 lines
5.0 KiB
Python
|
|
"""Столбчатый график на QPainter.
|
|||
|
|
|
|||
|
|
QtCharts исключён из сборки: он тянет за собой десятки мегабайт ради одной
|
|||
|
|
диаграммы. Здесь нужен один тип графика, и нарисовать его вручную дешевле,
|
|||
|
|
чем удвоить размер exe.
|
|||
|
|
|
|||
|
|
Умеет то, без чего график врал бы: отрицательные значения (убыток — это
|
|||
|
|
нормально) и нулевую линию на своём месте.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from decimal import Decimal
|
|||
|
|
|
|||
|
|
from PySide6.QtCore import QPointF, QRectF, Qt
|
|||
|
|
from PySide6.QtGui import QColor, QFontMetrics, QPainter, QPen
|
|||
|
|
from PySide6.QtWidgets import QSizePolicy, QToolTip, QWidget
|
|||
|
|
|
|||
|
|
from .. import money as m
|
|||
|
|
from . import theme
|
|||
|
|
|
|||
|
|
PADDING_LEFT = 8
|
|||
|
|
PADDING_RIGHT = 8
|
|||
|
|
PADDING_TOP = 18
|
|||
|
|
LABEL_HEIGHT = 34
|
|||
|
|
MIN_BAR_WIDTH = 14
|
|||
|
|
BAR_GAP = 6
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Bar:
|
|||
|
|
label: str
|
|||
|
|
value: Decimal
|
|||
|
|
tooltip: str = ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
class BarChart(QWidget):
|
|||
|
|
"""Столбики со знаком. Подпись под каждым, значение — во всплывающей подсказке."""
|
|||
|
|
|
|||
|
|
def __init__(self, parent=None):
|
|||
|
|
super().__init__(parent)
|
|||
|
|
self.bars: list[Bar] = []
|
|||
|
|
self.currency = "₽"
|
|||
|
|
self.positive = QColor(theme.ACCENT)
|
|||
|
|
self.negative = QColor(theme.DANGER)
|
|||
|
|
self._rects: list[tuple[QRectF, Bar]] = []
|
|||
|
|
|
|||
|
|
self.setMinimumHeight(190)
|
|||
|
|
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|||
|
|
self.setMouseTracking(True)
|
|||
|
|
|
|||
|
|
def set_bars(self, bars: list[Bar], currency: str = "₽", positive: str = "") -> None:
|
|||
|
|
self.bars = list(bars)
|
|||
|
|
self.currency = currency
|
|||
|
|
if positive:
|
|||
|
|
self.positive = QColor(positive)
|
|||
|
|
self.update()
|
|||
|
|
|
|||
|
|
# --- отрисовка ---
|
|||
|
|
|
|||
|
|
def paintEvent(self, event) -> None:
|
|||
|
|
painter = QPainter(self)
|
|||
|
|
painter.setRenderHint(QPainter.Antialiasing)
|
|||
|
|
self._rects.clear()
|
|||
|
|
|
|||
|
|
area = QRectF(self.rect()).adjusted(PADDING_LEFT, PADDING_TOP, -PADDING_RIGHT, -LABEL_HEIGHT)
|
|||
|
|
if not self.bars or area.height() <= 0:
|
|||
|
|
painter.setPen(QColor(theme.TEXT_DIM))
|
|||
|
|
painter.drawText(self.rect(), Qt.AlignCenter, "Пока нечего показывать")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
values = [float(bar.value) for bar in self.bars]
|
|||
|
|
top = max(max(values), 0.0)
|
|||
|
|
bottom = min(min(values), 0.0)
|
|||
|
|
span = top - bottom
|
|||
|
|
if span <= 0:
|
|||
|
|
# Все значения нулевые: рисуем только базовую линию.
|
|||
|
|
span, top = 1.0, 1.0
|
|||
|
|
|
|||
|
|
zero_y = area.bottom() - (0.0 - bottom) / span * area.height()
|
|||
|
|
slot = area.width() / len(self.bars)
|
|||
|
|
width = max(MIN_BAR_WIDTH, min(slot - BAR_GAP, 64.0))
|
|||
|
|
|
|||
|
|
self._draw_zero_line(painter, area, zero_y, bottom < 0)
|
|||
|
|
|
|||
|
|
metrics = QFontMetrics(painter.font())
|
|||
|
|
for index, bar in enumerate(self.bars):
|
|||
|
|
center = area.left() + slot * (index + 0.5)
|
|||
|
|
height = abs(float(bar.value)) / span * area.height()
|
|||
|
|
rect = QRectF(
|
|||
|
|
center - width / 2,
|
|||
|
|
zero_y - height if bar.value >= 0 else zero_y,
|
|||
|
|
width,
|
|||
|
|
max(height, 1.0),
|
|||
|
|
)
|
|||
|
|
color = self.positive if bar.value >= 0 else self.negative
|
|||
|
|
painter.setPen(Qt.NoPen)
|
|||
|
|
painter.setBrush(color)
|
|||
|
|
painter.drawRoundedRect(rect, 3, 3)
|
|||
|
|
self._rects.append((rect, bar))
|
|||
|
|
|
|||
|
|
self._draw_label(painter, metrics, bar, center, slot)
|
|||
|
|
|
|||
|
|
painter.end()
|
|||
|
|
|
|||
|
|
def _draw_zero_line(self, painter: QPainter, area: QRectF, zero_y: float, visible: bool) -> None:
|
|||
|
|
pen = QPen(QColor(theme.BORDER))
|
|||
|
|
pen.setStyle(Qt.SolidLine if visible else Qt.DotLine)
|
|||
|
|
painter.setPen(pen)
|
|||
|
|
painter.drawLine(QPointF(area.left(), zero_y), QPointF(area.right(), zero_y))
|
|||
|
|
|
|||
|
|
def _draw_label(self, painter: QPainter, metrics: QFontMetrics, bar: Bar, center: float, slot: float) -> None:
|
|||
|
|
painter.setPen(QColor(theme.TEXT_DIM))
|
|||
|
|
text = metrics.elidedText(bar.label, Qt.ElideRight, int(slot) - 2)
|
|||
|
|
painter.drawText(
|
|||
|
|
QRectF(center - slot / 2, self.height() - LABEL_HEIGHT + 4, slot, LABEL_HEIGHT - 6),
|
|||
|
|
Qt.AlignHCenter | Qt.AlignTop,
|
|||
|
|
text,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# --- подсказка ---
|
|||
|
|
|
|||
|
|
def mouseMoveEvent(self, event) -> None:
|
|||
|
|
position = event.position()
|
|||
|
|
for rect, bar in self._rects:
|
|||
|
|
# Ловим по всей высоте столбца: попасть мышью в тонкую полоску
|
|||
|
|
# у нуля невозможно.
|
|||
|
|
if rect.left() <= position.x() <= rect.right():
|
|||
|
|
QToolTip.showText(
|
|||
|
|
event.globalPosition().toPoint(),
|
|||
|
|
bar.tooltip or f"{bar.label}: {m.fmt_money(bar.value, self.currency)}",
|
|||
|
|
self,
|
|||
|
|
)
|
|||
|
|
return
|
|||
|
|
QToolTip.hideText()
|