418 lines
14 KiB
Python
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",
|
|
)
|