Files
pyGUI/src/pygui/backend/visualization/graph_quick_item.py
T

418 lines
14 KiB
Python

"""QQuickPaintedItem line chart for embedding in QML.
Draws multiple sensor-data line series with axis labels, grid lines,
auto-scaled Y range, legend, and a dark-theme default palette that
complements Theme.qml.
Usage from QML::
import ISC.Wafer
GraphQuickItem {
id: graph
anchors.fill: parent
seriesData: [...]
sensorNames: ["Sensor1", "Sensor2"]
title: "Sensor Temperature Over Time"
yLabel: "Temperature (°C)"
xLabel: "Measurement"
}
"""
from __future__ import annotations
from PySide6.QtCore import Property, QRectF, Qt, Signal
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem
QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1
@QmlElement
class GraphQuickItem(QQuickPaintedItem):
"""Painted line chart; driven by series data passed via QML property bindings."""
seriesDataChanged = Signal()
sensorNamesChanged = Signal()
titleChanged = Signal()
xLabelChanged = Signal()
yLabelChanged = Signal()
colorsChanged = Signal()
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._series_data: list[list[float]] = [] # [[sensor1_vals...], [sensor2_vals...]]
self._sensor_names: list[str] = [] # parallel to series_data
self._title: str = ""
self._x_label: str = "Measurement"
self._y_label: str = "Temperature (°C)"
self._show_legend: bool = True
# Dark-theme colour defaults (match Theme.qml where possible)
self._bg_color = QColor("#1A1A1A") # tone200
self._grid_color = QColor("#2A2A2A") # toneBorder
self._axis_color = QColor("#A8A8A8") # toneMute
self._text_color = QColor("#F2F2F2") # toneText
self._series_colors: list[QColor] = [
QColor("#FF5757"), # Red
QColor("#42A5F5"), # Blue
QColor("#66BB6A"), # Green
QColor("#FFA726"), # Orange
QColor("#AB47BC"), # Purple
QColor("#00BCD4"), # Cyan
QColor("#FF7043"), # Deep Orange
QColor("#795548"), # Brown
QColor("#5C6BC0"), # Indigo
QColor("#307D75"), # Teal
]
self._min_y: float = 0.0
self._max_y: float = 150.0
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
# ── Qt properties (QML-bindable) ──────────────────────────────────────────
@Property("QVariantList", notify=seriesDataChanged)
def seriesData(self) -> list:
return self._series_data
@seriesData.setter
def seriesData(self, val: list) -> None:
self._series_data = [list(s) for s in (val or [])]
self._auto_range()
self.seriesDataChanged.emit()
self.update()
@Property("QStringList", notify=sensorNamesChanged)
def sensorNames(self) -> list:
return self._sensor_names
@sensorNames.setter
def sensorNames(self, val: list) -> None:
self._sensor_names = list(val or [])
self.sensorNamesChanged.emit()
self.update()
@Property(str, notify=titleChanged)
def title(self) -> str:
return self._title
@title.setter
def title(self, val: str) -> None:
self._title = str(val or "")
self.titleChanged.emit()
self.update()
@Property(str, notify=xLabelChanged)
def xLabel(self) -> str:
return self._x_label
@xLabel.setter
def xLabel(self, val: str) -> None:
self._x_label = str(val or "")
self.xLabelChanged.emit()
self.update()
@Property(str, notify=yLabelChanged)
def yLabel(self) -> str:
return self._y_label
@yLabel.setter
def yLabel(self, val: str) -> None:
self._y_label = str(val or "")
self.yLabelChanged.emit()
self.update()
@Property(bool, notify=colorsChanged)
def showLegend(self) -> bool:
return self._show_legend
@showLegend.setter
def showLegend(self, val: bool) -> None:
self._show_legend = bool(val)
self.colorsChanged.emit()
self.update()
# ── Colour properties (QML can bind Theme tokens here) ────────────────────
@Property(QColor, notify=colorsChanged)
def backgroundColor(self) -> QColor:
return self._bg_color
@backgroundColor.setter
def backgroundColor(self, c: QColor) -> None:
self._bg_color = c
self.update()
@Property(QColor, notify=colorsChanged)
def gridColor(self) -> QColor:
return self._grid_color
@gridColor.setter
def gridColor(self, c: QColor) -> None:
self._grid_color = c
self.update()
@Property(QColor, notify=colorsChanged)
def axisColor(self) -> QColor:
return self._axis_color
@axisColor.setter
def axisColor(self, c: QColor) -> None:
self._axis_color = c
self.update()
@Property(QColor, notify=colorsChanged)
def textColor(self) -> QColor:
return self._text_color
@textColor.setter
def textColor(self, c: QColor) -> None:
self._text_color = c
self.update()
@Property("QVariantList", notify=colorsChanged)
def seriesColors(self) -> list:
"""Return hex strings of the current series color palette."""
return [c.name() for c in self._series_colors]
@seriesColors.setter
def seriesColors(self, hex_list: list) -> None:
colors = []
for h in (hex_list or []):
try:
colors.append(QColor(str(h)))
except Exception:
colors.append(QColor("#FFFFFF"))
if colors:
self._series_colors = colors
self.update()
# ── internal ──────────────────────────────────────────────────────────────
def _auto_range(self) -> None:
"""Set Y range to cover all data with 10% padding."""
all_vals: list[float] = []
for series in self._series_data:
for v in series:
try:
all_vals.append(float(v))
except (ValueError, TypeError):
pass
if not all_vals:
return
mn = min(all_vals)
mx = max(all_vals)
if mx <= mn:
mn -= 5.0
mx += 5.0
pad = (mx - mn) * 0.1
self._min_y = mn - pad
self._max_y = mx + pad
def _plot_rect(self) -> QRectF:
w = self.width()
h = self.height()
pl = self._padding.get("left", 60)
pr = self._padding.get("right", 20)
pt = self._padding.get("top", 30)
pb = self._padding.get("bottom", 50)
return QRectF(pl, pt, max(10, w - pl - pr), max(10, h - pt - pb))
# ── paint ─────────────────────────────────────────────────────────────────
def paint(self, painter: QPainter) -> None:
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
w = self.width()
h = self.height()
if w < 50 or h < 50:
return
pr = self._plot_rect()
plot_left = pr.left()
plot_top = pr.top()
plot_w = pr.width()
plot_h = pr.height()
# --- Background fill ---
painter.fillRect(0, 0, int(w), int(h), self._bg_color)
# --- Title ---
if self._title:
title_font = QFont()
title_font.setPixelSize(14)
title_font.setBold(True)
painter.setFont(title_font)
painter.setPen(QPen(self._text_color))
painter.drawText(
QRectF(0, 4, w, 24),
Qt.AlignmentFlag.AlignHCenter,
self._title,
)
if not self._series_data:
self._paint_empty(painter, pr)
return
# --- Determine Y tick count and values ---
n_y_ticks = max(2, min(8, int(plot_h // 40)))
y_range = self._max_y - self._min_y
if y_range <= 0:
y_range = 10.0
y_step = y_range / (n_y_ticks - 1) if n_y_ticks > 1 else y_range
# --- Grid & Y-axis labels ---
grid_pen = QPen(self._grid_color, 1)
axis_pen = QPen(self._axis_color, 1)
label_font = QFont()
label_font.setPixelSize(10)
painter.setFont(label_font)
fm = QFontMetrics(label_font)
for i in range(n_y_ticks):
y_val = self._min_y + i * y_step
y_frac = 1.0 - (i / (n_y_ticks - 1)) if n_y_ticks > 1 else 0.5
y_px = plot_top + y_frac * plot_h
# Grid line
painter.setPen(grid_pen)
painter.drawLine(
int(plot_left), int(y_px),
int(plot_left + plot_w), int(y_px),
)
# Label
label_str = f"{y_val:.1f}"
tw = fm.horizontalAdvance(label_str)
painter.setPen(axis_pen)
painter.drawText(
int(plot_left - tw - 6), int(y_px + fm.height() // 2 - 3),
label_str,
)
# --- Y axis label (rotated) ---
if self._y_label:
painter.save()
painter.setPen(QPen(self._axis_color))
label_font_b = QFont()
label_font_b.setPixelSize(11)
painter.setFont(label_font_b)
painter.translate(14, plot_top + plot_h / 2)
painter.rotate(-90)
painter.drawText(0, 0, self._y_label)
painter.restore()
# --- X axis label ---
if self._x_label:
painter.setPen(QPen(self._axis_color))
label_font_b = QFont()
label_font_b.setPixelSize(11)
painter.setFont(label_font_b)
painter.drawText(
QRectF(plot_left, h - 18, plot_w, 16),
Qt.AlignmentFlag.AlignHCenter,
self._x_label,
)
# --- X-axis ticks ---
num_points = max(len(s) for s in self._series_data) if self._series_data else 0
if num_points > 1:
n_x_ticks = min(num_points, max(2, int(plot_w // 80)))
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1)
for i in range(n_x_ticks):
idx = int(round(i * x_tick_step))
x_frac = idx / (num_points - 1)
x_px = plot_left + x_frac * plot_w
painter.setPen(axis_pen)
painter.drawText(
int(x_px - 12), int(plot_top + plot_h + 14),
str(idx + 1),
)
# --- Axes boundary ---
border_pen = QPen(self._axis_color, 1)
painter.setPen(border_pen)
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
# --- Figure out max series length ---
max_len = max(len(s) for s in self._series_data) if self._series_data else 0
if max_len < 1:
return
# --- Draw each series ---
for si, series in enumerate(self._series_data):
if len(series) < 1:
continue
color = self._series_colors[si % len(self._series_colors)]
pen = QPen(color, 2)
painter.setPen(pen)
pts: list[tuple[float, float]] = []
for i, val in enumerate(series):
try:
v = float(val)
except (ValueError, TypeError):
continue
x_frac = i / (max_len - 1) if max_len > 1 else 0.5
y_frac = 1.0 - ((v - self._min_y) / y_range) if y_range > 0 else 0.5
x_px = plot_left + x_frac * plot_w
y_px = plot_top + y_frac * plot_h
pts.append((x_px, y_px))
if len(pts) < 2:
if len(pts) == 1:
painter.drawPoint(int(pts[0][0]), int(pts[0][1]))
continue
for i in range(len(pts) - 1):
painter.drawLine(
int(pts[i][0]), int(pts[i][1]),
int(pts[i + 1][0]), int(pts[i + 1][1]),
)
# --- Legend ---
if self._show_legend and self._sensor_names:
legend_font = QFont()
legend_font.setPixelSize(10)
painter.setFont(legend_font)
lfm = QFontMetrics(legend_font)
# Compute legend dimensions
lh = 16
max_name_w = max(lfm.horizontalAdvance(n) for n in self._sensor_names)
lw = max_name_w + 24
legend_count = min(len(self._sensor_names), len(self._series_data))
lh_total = legend_count * lh + 4
legend_x = int(plot_left + plot_w - lw - 8)
legend_y = int(plot_top + 8)
# Background
painter.fillRect(legend_x, legend_y, lw, lh_total, QColor(0, 0, 0, 140))
for i in range(legend_count):
y_pos = legend_y + 4 + i * lh
color = self._series_colors[i % len(self._series_colors)]
# Color swatch
painter.fillRect(legend_x + 4, y_pos + 2, 12, 10, color)
# Label
painter.setPen(QPen(self._text_color))
painter.drawText(legend_x + 20, y_pos + 11, self._sensor_names[i])
def _paint_empty(self, painter: QPainter, pr: QRectF) -> None:
"""Draw placeholder when no data is available."""
painter.setPen(QPen(self._axis_color))
empty_font = QFont()
empty_font.setPixelSize(14)
painter.setFont(empty_font)
painter.drawText(
pr,
Qt.AlignmentFlag.AlignCenter,
"No data — read a wafer or open a CSV file",
)