"""QQuickPaintedItem trend chart — running-average temperature over time. Renders a single polyline series (per-frame average temperatures) using QPainter. Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`. The series is driven by the existing `streamController.trendData` signal, which emits a JSON-encoded list of floats from both review-mode replay and live-mode streaming. """ from __future__ import annotations import json import logging from typing import Optional from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon from PySide6.QtQml import QmlElement from PySide6.QtQuick import QQuickPaintedItem log = logging.getLogger(__name__) QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_MAJOR_VERSION = 1 @QmlElement class TrendChartItem(QQuickPaintedItem): """Painted trend chart; driven by a list of floats via the `data` property.""" dataChanged = Signal() hasDataChanged = Signal() lineColorChanged = Signal() gridColorChanged = Signal() textColorChanged = Signal() paddingChanged = Signal() def __init__(self, parent: Optional[QQuickPaintedItem] = None) -> None: super().__init__(parent) self.setRenderTarget(QQuickPaintedItem.RenderTarget.FramebufferObject) self.setAntialiasing(True) self.setFillColor(QColor("transparent")) self._data: list[float] = [] self._padding: int = 8 self._line_color = QColor("#5B9DF5") self._grid_color = QColor("#2A3441") self._text_color = QColor("#CBD5E1") # ── Qt properties ───────────────────────────────────────────────────── @Property("QVariantList", notify=dataChanged) def data(self) -> list[float]: return self._data @data.setter def data(self, val) -> None: coerced = self._coerce_floats(val) if coerced == self._data: return self._data = coerced self.dataChanged.emit() self.hasDataChanged.emit() self.update() @Property(bool, notify=hasDataChanged) def hasData(self) -> bool: """True when the data list has at least one point. QML can bind to this safely (QVariantList `.length` is not bindable). """ return len(self._data) > 0 @Property(QColor, notify=lineColorChanged) def lineColor(self) -> QColor: return self._line_color @lineColor.setter def lineColor(self, c: QColor) -> None: if c == self._line_color: return self._line_color = QColor(c) self.lineColorChanged.emit() self.update() @Property(QColor, notify=gridColorChanged) def gridColor(self) -> QColor: return self._grid_color @gridColor.setter def gridColor(self, c: QColor) -> None: if c == self._grid_color: return self._grid_color = QColor(c) self.gridColorChanged.emit() self.update() @Property(QColor, notify=textColorChanged) def textColor(self) -> QColor: return self._text_color @textColor.setter def textColor(self, c: QColor) -> None: if c == self._text_color: return self._text_color = QColor(c) self.textColorChanged.emit() self.update() @Property(int, notify=paddingChanged) def padding(self) -> int: return self._padding @padding.setter def padding(self, val: int) -> None: v = max(0, int(val)) if v == self._padding: return self._padding = v self.paddingChanged.emit() self.update() # ── Convenience slot for QML: accept a JSON string from a Signal(str) ── @Slot(str) def setDataFromJson(self, avgs_json: str) -> None: """Slot for QML Connections handler: parse JSON array and update data. Mirrors `streamController.trendData` (which emits JSON-encoded floats). """ try: parsed = json.loads(avgs_json) if avgs_json else [] except (json.JSONDecodeError, TypeError) as exc: log.error("TrendChartItem: failed to parse trend JSON: %s", exc) return coerced = self._coerce_floats(parsed) if coerced == self._data: return self._data = coerced self.dataChanged.emit() self.hasDataChanged.emit() self.update() # ── paint ───────────────────────────────────────────────────────────── def paint(self, painter: QPainter) -> None: # type: ignore[override] painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True) w = self.width() h = self.height() if w <= 0 or h <= 0: return plot_rect = QRectF(self._padding, self._padding, max(1, w - 2 * self._padding), max(1, h - 2 * self._padding)) self._draw_grid(painter, plot_rect) if len(self._data) < 2: return y_min, y_max = self._y_range() self._draw_axes_labels(painter, plot_rect, y_min, y_max) self._draw_line(painter, plot_rect, y_min, y_max) # ── internals ───────────────────────────────────────────────────────── def _coerce_floats(self, val) -> list[float]: if not val: return [] out: list[float] = [] for v in val: try: out.append(float(v)) except (TypeError, ValueError): continue return out def _y_range(self) -> tuple[float, float]: lo = min(self._data) hi = max(self._data) if hi - lo < 1e-9: return lo - 5.0, hi + 5.0 buf = (hi - lo) * 0.1 return lo - buf, hi + buf def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float: if n <= 1: return plot_rect.left() return plot_rect.left() + (i / (n - 1)) * plot_rect.width() def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float: if y_max - y_min < 1e-9: return plot_rect.center().y() t = (v - y_min) / (y_max - y_min) return plot_rect.bottom() - t * plot_rect.height() def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None: pen = QPen(self._grid_color) pen.setWidthF(1.0) pen.setCosmetic(True) painter.setPen(pen) rows = 4 for i in range(rows + 1): y = plot_rect.top() + (i / rows) * plot_rect.height() painter.drawLine(int(plot_rect.left()), int(y), int(plot_rect.right()), int(y)) def _draw_axes_labels( self, painter: QPainter, plot_rect: QRectF, y_min: float, y_max: float, ) -> None: painter.setPen(self._text_color) font = QFont(painter.font()) font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85)) painter.setFont(font) rows = 4 for i in range(rows + 1): t = i / rows y_val = y_max - t * (y_max - y_min) y_px = plot_rect.top() + t * plot_rect.height() label = f"{y_val:.1f}" painter.drawText(QRectF(0, y_px - 7, self._padding, 14), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, label) def _draw_line( self, painter: QPainter, plot_rect: QRectF, y_min: float, y_max: float, ) -> None: n = len(self._data) pen = QPen(self._line_color) pen.setWidthF(2.0) pen.setCosmetic(True) pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) painter.setPen(pen) poly = QPolygon() for i, v in enumerate(self._data): px = self._x_to_px(i, n, plot_rect) py = self._y_to_px(v, y_min, y_max, plot_rect) poly.append(QPoint(int(px), int(py))) painter.drawPolyline(poly) # Trailing dot at the last data point last_x = int(self._x_to_px(n - 1, n, plot_rect)) last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect)) painter.setBrush(self._line_color) painter.setPen(Qt.PenStyle.NoPen) radius = 3 painter.drawEllipse(last_x - radius, last_y - radius, radius * 2, radius * 2)