"""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", )