Rename project to OBDash + per-metric colored multi-axis
Rename: the app is vehicle-agnostic, so 'ford-obd' was wrong. Rebranded all code/docs/profile authors to OBDash; Gitea repo renamed justin/ford-obd -> justin/obdash (remote + description updated). Ford the make and the ford-6.0-powerstroke profile are unchanged (that vehicle really is a Ford). Multi-axis upgrade (per request): - MultiAxisPlot now gives each METRIC its own Y axis, each axis colored to match its line; the primary metric owns the LEFT axis, others stack right. - Click a line to promote it to the left axis (sigClicked -> set_primary). - Cleaner teardown (no removeItem warnings); axis label no longer doubles the unit; Normalize round-trips. Validated headless: colored per-metric axes, promote-to-left, gauge view, normalize toggle, profile switch; obdcore + diagnostics tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
This commit is contained in:
+107
-51
@@ -1,5 +1,5 @@
|
||||
"""Custom widgets for the ford-obd GUI: a unit-grouped multi-axis plot, a
|
||||
simple single-axis plot, and an arc gauge / gauge grid.
|
||||
"""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) /
|
||||
@@ -10,8 +10,6 @@ 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."""
|
||||
@@ -58,9 +56,14 @@ class SinglePlot(QtWidgets.QWidget):
|
||||
|
||||
|
||||
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."""
|
||||
"""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__()
|
||||
@@ -73,40 +76,29 @@ class MultiAxisPlot(QtWidgets.QWidget):
|
||||
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._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 _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)
|
||||
if key in self._curves:
|
||||
return
|
||||
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}
|
||||
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)
|
||||
@@ -117,30 +109,94 @@ class MultiAxisPlot(QtWidgets.QWidget):
|
||||
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
|
||||
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 key in list(self._curves):
|
||||
self.remove_curve(key)
|
||||
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 # multi-axis labels itself per unit
|
||||
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 e in self._units.values():
|
||||
if not e["base"]:
|
||||
e["vb"].setGeometry(rect)
|
||||
e["vb"].linkedViewChanged(self.base_vb, e["vb"].XAxis)
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user