"""Custom widgets for the OBDash GUI: a multi-axis plot (one colored axis per metric), a single-axis plot, and a car-style 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 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 METRIC, each axis colored to match its line, so mixed-scale signals are all readable at their true values. The 'primary' metric owns the LEFT axis; the rest stack on the right. Click a line to promote it to the left axis. Beyond MAX_RIGHT extra metrics, overflow curves share the primary axis (rare; plot fewer to compare).""" MAX_RIGHT = 5 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._curves = {} # key -> {color,name,unit,curve,vb,ax} self._order = [] # insertion order of keys self._primary = None self.base_vb.sigResized.connect(self._sync) def add_curve(self, key, name, unit, color): if key in self._curves: return curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=2), name=name) curve.setClickable(True, width=8) curve.sigClicked.connect(lambda *_a, k=key: self.set_primary(k)) self._curves[key] = {"color": color, "name": name, "unit": unit, "curve": curve, "vb": None, "ax": None} self._order.append(key) if self._primary is None: self._primary = key self._rebuild() def set_primary(self, key): """Move this metric's axis to the LEFT (make it the primary axis).""" if key in self._curves and key != self._primary: self._primary = key self._rebuild() 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 self._order.remove(key) self._detach(c) if self._primary == key: self._primary = self._order[0] if self._order else None self._rebuild() def clear(self): for c in self._curves.values(): self._detach(c) self._curves = {} self._order = [] self._primary = None self.legend.clear() def set_y_label(self, _text): pass # labels itself per metric def set_background(self, bg): self.glw.setBackground(bg) # -- internals -- def _detach(self, c): """Remove a curve from its current ViewBox and tear down its extra axis (the base left axis is kept). Idempotent -- safe to call repeatedly.""" cur = c["curve"] vb = c.get("vb") if vb is not None: try: vb.removeItem(cur) except Exception: pass ax = c.get("ax") if ax is not None and ax is not self.p.getAxis("left"): try: self.p.layout.removeItem(ax) except Exception: pass sc = ax.scene() if sc is not None: sc.removeItem(ax) if vb is not None and vb is not self.base_vb: sc = vb.scene() if sc is not None: sc.removeItem(vb) c["vb"] = None c["ax"] = None def _rebuild(self): for c in self._curves.values(): self._detach(c) self.legend.clear() if self._primary is None: return order = [self._primary] + [k for k in self._order if k != self._primary] col = 3 left = self.p.getAxis("left") for idx, key in enumerate(order): c = self._curves[key] cur = c["curve"] pen = pg.mkPen(c["color"]) if idx == 0: # primary -> left axis self.base_vb.addItem(cur) left.setLabel(c["name"]) # name already carries the unit left.setPen(pen); left.setTextPen(pen) c["vb"] = self.base_vb; c["ax"] = left elif idx <= self.MAX_RIGHT: # right axis, colored to line vb = pg.ViewBox() ax = pg.AxisItem("right") self.p.layout.addItem(ax, 2, col); col += 1 self.p.scene().addItem(vb) ax.linkToView(vb); vb.setXLink(self.p) ax.setLabel(c["name"]) ax.setPen(pen); ax.setTextPen(pen) vb.addItem(cur) c["vb"] = vb; c["ax"] = ax else: # overflow -> primary axis self.base_vb.addItem(cur) c["vb"] = self.base_vb; c["ax"] = None self.legend.addItem(cur, c["name"]) self._sync() def _sync(self): rect = self.base_vb.sceneBoundingRect() for c in self._curves.values(): vb = c.get("vb") if vb is not None and vb is not self.base_vb: vb.setGeometry(rect) vb.linkedViewChanged(self.base_vb, 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(172, 176) 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))) # gauge geometry: a 270-degree dial, gap at the bottom (like a tach) _START = 225.0 # value=min angle (math degrees, 0=3 o'clock, CCW+) _SWEEP = 270.0 # clockwise as value rises def _ang(self, frac): return self._START - self._SWEEP * max(0.0, min(1.0, frac)) 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")) side = min(w, h - 14) R = side / 2 - 6 cx, cy = w / 2.0, R + 8 def pt(ang_deg, r): a = math.radians(ang_deg) return QtCore.QPointF(cx + r * math.cos(a), cy - r * math.sin(a)) # dial face + bezel p.setPen(QtGui.QPen(QtGui.QColor("#2a2a2a"), 3)) p.setBrush(QtGui.QColor("#0c0c0c")) p.drawEllipse(QtCore.QPointF(cx, cy), R, R) # value progress arc just inside the rim rimrect = QtCore.QRectF(cx - R + 7, cy - R + 7, 2 * (R - 7), 2 * (R - 7)) if self.value is not None: pen = QtGui.QPen(QtGui.QColor(self.accent), 4) pen.setCapStyle(QtCore.Qt.FlatCap) p.setPen(pen) p.drawArc(rimrect, int(self._START * 16), int(-self._SWEEP * self._frac(self.value) * 16)) # tick marks + numeric scale majors = 6 fnt = p.font(); fnt.setPointSize(7); p.setFont(fnt) for i in range(majors * 5 + 1): frac = i / (majors * 5) a = self._ang(frac) major = (i % 5 == 0) p.setPen(QtGui.QPen(QtGui.QColor("#ddd" if major else "#555"), 2 if major else 1)) p.drawLine(pt(a, R - 3), pt(a, R - (13 if major else 7))) if major: val = self.vmin + frac * (self.vmax - self.vmin) span = abs(self.vmax - self.vmin) lbl = f"{val:.0f}" if span >= 50 else f"{val:.1f}" if span >= 5 else f"{val:g}" lp = pt(a, R - 25) p.setPen(QtGui.QColor("#9a9a9a")) p.drawText(QtCore.QRectF(lp.x() - 18, lp.y() - 7, 36, 14), QtCore.Qt.AlignCenter, lbl) # peak marker (thin amber tick) if self.peak is not None and self.value is not None and self.peak != self.value: a = self._ang(self._frac(self.peak)) p.setPen(QtGui.QPen(QtGui.QColor("#e6c84b"), 2)) p.drawLine(pt(a, R - 3), pt(a, R - 14)) # needle if self.value is not None: a = self._ang(self._frac(self.value)) tip = pt(a, R - 16) b1 = pt(a + 90, 4.5) b2 = pt(a - 90, 4.5) tail = pt(a + 180, 10) path = QtGui.QPainterPath() path.moveTo(b1); path.lineTo(tip); path.lineTo(b2); path.lineTo(tail) path.closeSubpath() p.setPen(QtCore.Qt.NoPen) p.setBrush(QtGui.QColor("#e6194B")) p.drawPath(path) # hub p.setBrush(QtGui.QColor("#999")); p.setPen(QtCore.Qt.NoPen) p.drawEllipse(QtCore.QPointF(cx, cy), 5, 5) # digital readout (lower face) p.setPen(QtGui.QColor("#fff")) fnt.setPointSize(13); fnt.setBold(True); p.setFont(fnt) val = "--" if self.value is None else f"{self.value:g}" p.drawText(QtCore.QRectF(cx - 55, cy + R * 0.30, 110, 22), QtCore.Qt.AlignCenter, val) # name + unit fnt.setPointSize(8); fnt.setBold(False); p.setFont(fnt) p.setPen(QtGui.QColor("#9a9a9a")) p.drawText(QtCore.QRectF(0, h - 15, 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)