"""Custom widgets for the ford-obd GUI: a unit-grouped multi-axis plot, a simple single-axis plot, and an arc gauge / gauge grid. All three plot/gauge containers share a small interface so the main window can swap between them: add_curve(key,name,unit,color) / set_data(key,xs,ys) / remove_curve(key) / clear(). """ import math from PySide6 import QtCore, QtGui, QtWidgets import pyqtgraph as pg MAX_EXTRA_AXES = 4 # base left axis + up to 4 right axes (5 units shown) class SinglePlot(QtWidgets.QWidget): """One shared Y axis. Used for the Normalize (% of range) mode.""" def __init__(self): super().__init__() lay = QtWidgets.QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) self.pw = pg.PlotWidget() self.pw.addLegend(offset=(10, 10)) self.pw.showGrid(x=True, y=True, alpha=0.25) self.pw.setLabel("bottom", "time", units="s") lay.addWidget(self.pw) self._curves = {} def set_y_label(self, text): self.pw.setLabel("left", text) def add_curve(self, key, name, unit, color): self._curves[key] = self.pw.plot([], [], name=name, pen=pg.mkPen(color, width=2)) def set_data(self, key, xs, ys): c = self._curves.get(key) if c is not None: c.setData(xs, ys) def remove_curve(self, key): c = self._curves.pop(key, None) if c is not None: self.pw.removeItem(c) leg = self.pw.plotItem.legend if leg: try: leg.removeItem(c) except Exception: pass def clear(self): for key in list(self._curves): self.remove_curve(key) def set_background(self, bg): self.pw.setBackground(bg) class MultiAxisPlot(QtWidgets.QWidget): """Overlay with one Y axis PER UNIT (psi / V / rpm / C / %), so mixed-scale signals are all readable at their true values. Curves are grouped by unit; each extra unit gets its own right-hand axis + linked ViewBox.""" def __init__(self): super().__init__() lay = QtWidgets.QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) self.glw = pg.GraphicsLayoutWidget() lay.addWidget(self.glw) self.p = self.glw.addPlot() self.p.showGrid(x=True, y=True, alpha=0.2) self.p.setLabel("bottom", "time", units="s") self.legend = self.p.addLegend(offset=(10, 10)) self.base_vb = self.p.vb self._units = {} # unit -> {vb, ax, base} self._curves = {} # key -> {curve, unit} self._next_col = 3 self.base_vb.sigResized.connect(self._sync) def _ensure_unit(self, unit): e = self._units.get(unit) if e is not None: return e if not self._units: # first unit -> base left axis self.p.setLabel("left", unit) e = {"vb": self.base_vb, "ax": self.p.getAxis("left"), "base": True} elif len(self._units) - 1 < MAX_EXTRA_AXES: # add a right axis vb = pg.ViewBox() ax = pg.AxisItem("right") self.p.layout.addItem(ax, 2, self._next_col) self._next_col += 1 self.p.scene().addItem(vb) ax.linkToView(vb) vb.setXLink(self.p) ax.setLabel(unit) e = {"vb": vb, "ax": ax, "base": False} self._sync() else: # out of axes -> reuse base e = self._units[next(iter(self._units))] self._units[unit] = e return e def add_curve(self, key, name, unit, color): e = self._ensure_unit(unit) curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=2), name=name) e["vb"].addItem(curve) self.legend.addItem(curve, name) self._curves[key] = {"curve": curve, "unit": unit} def set_data(self, key, xs, ys): c = self._curves.get(key) if c is not None: c["curve"].setData(xs, ys) def remove_curve(self, key): c = self._curves.pop(key, None) if not c: return e = self._units.get(c["unit"]) if e: e["vb"].removeItem(c["curve"]) try: self.legend.removeItem(c["curve"]) except Exception: pass def clear(self): for key in list(self._curves): self.remove_curve(key) def set_y_label(self, _text): pass # multi-axis labels itself per unit def set_background(self, bg): self.glw.setBackground(bg) def _sync(self): rect = self.base_vb.sceneBoundingRect() for e in self._units.values(): if not e["base"]: e["vb"].setGeometry(rect) e["vb"].linkedViewChanged(self.base_vb, e["vb"].XAxis) class ArcGauge(QtWidgets.QWidget): """A 270-degree arc gauge for a single signal: filled arc to the current value, peak tick, big numeric readout, name + unit.""" def __init__(self, name, unit, vmin, vmax, accent="#3cb44b"): super().__init__() self.name = name self.unit = unit self.vmin = float(vmin) self.vmax = float(vmax) if vmax != vmin else float(vmin) + 1.0 self.accent = QtGui.QColor(accent) self.value = None self.peak = None self.setMinimumSize(150, 130) def set_value(self, v, peak=None): self.value = v self.peak = peak self.update() def _frac(self, v): return max(0.0, min(1.0, (v - self.vmin) / (self.vmax - self.vmin))) def paintEvent(self, _ev): p = QtGui.QPainter(self) p.setRenderHint(QtGui.QPainter.Antialiasing) w, h = self.width(), self.height() p.fillRect(self.rect(), QtGui.QColor("#141414")) # own dark bg (theme-proof) m = 14 side = min(w, h - 18) rect = QtCore.QRectF((w - side) / 2 + m / 2, m / 2, side - m, side - m) start, span = 225 * 16, -270 * 16 # 270deg sweep, top-open down def arc_pen(color, width): pen = QtGui.QPen(QtGui.QColor(color), width) pen.setCapStyle(QtCore.Qt.RoundCap) return pen p.setPen(arc_pen("#333", 9)) p.drawArc(rect, start, span) if self.value is not None: frac = self._frac(self.value) p.setPen(arc_pen(self.accent, 9)) p.drawArc(rect, start, int(span * frac)) if self.peak is not None and self.peak != self.value: pf = self._frac(self.peak) ang = (225 - 270 * pf) p.setPen(QtGui.QPen(QtGui.QColor("#e6c84b"), 2)) cx, cy = rect.center().x(), rect.center().y() r1, r2 = side / 2 - m - 9, side / 2 - m + 2 a = math.radians(ang) p.drawLine(QtCore.QPointF(cx + r1 * math.cos(a), cy - r1 * math.sin(a)), QtCore.QPointF(cx + r2 * math.cos(a), cy - r2 * math.sin(a))) p.setPen(QtGui.QColor("#eee")) f = p.font(); f.setPointSize(15); f.setBold(True); p.setFont(f) val = "--" if self.value is None else f"{self.value:g}" p.drawText(rect, QtCore.Qt.AlignCenter, val) f.setPointSize(8); f.setBold(False); p.setFont(f) p.setPen(QtGui.QColor("#999")) p.drawText(QtCore.QRectF(0, h - 16, w, 14), QtCore.Qt.AlignCenter, f"{self.name} ({self.unit})" if self.unit else self.name) p.end() class GaugeGrid(QtWidgets.QScrollArea): """Scrollable grid of ArcGauges, one per signal.""" def __init__(self): super().__init__() self.setWidgetResizable(True) self.setStyleSheet("QScrollArea, QWidget { background: #111; }") self._inner = QtWidgets.QWidget() self._grid = QtWidgets.QGridLayout(self._inner) self._grid.setSpacing(8) self.setWidget(self._inner) self._gauges = {} self._cols = 4 def rebuild(self, specs): """specs: list of (key, name, unit, vmin, vmax, accent).""" while self._grid.count(): item = self._grid.takeAt(0) wdg = item.widget() if wdg: wdg.deleteLater() self._gauges = {} for i, (key, name, unit, vmin, vmax, accent) in enumerate(specs): g = ArcGauge(name, unit, vmin, vmax, accent) self._gauges[key] = g self._grid.addWidget(g, i // self._cols, i % self._cols) def keys(self): return set(self._gauges) def set_value(self, key, v, peak=None): g = self._gauges.get(key) if g is not None: g.set_value(v, peak)