P2: true multi-axis overlay + gauge view
gui/widgets.py: - MultiAxisPlot -- overlay with one Y axis PER UNIT (psi/V/rpm/C/%), linked ViewBoxes on X, so mixed-scale signals are readable at true values (base left axis + up to 4 right axes). - SinglePlot -- one shared axis for the Normalize (% of range) mode. - ArcGauge -- 270deg arc gauge with peak tick + numeric readout, own dark bg. - GaugeGrid -- scrollable grid of gauges. gui/main.py: - Graph page is now a multi-axis/single-axis sub-stack; Normalize toggles between true multi-axis (raw) and single-axis (%). curves map key->color; plot ops route to the active graph widget. - Gauge View menu enabled (3rd center page); gauges update on tick with peak. - Theme applies to both plot widgets; profile switch clears graphs/gauges. Fix: ArcGauge QPen built via setCapStyle (the QPen(...cap=...) kwarg segfaults PySide6). Validated headless: driving preset -> 6 unit groups across 5 axes, gauge view renders, normalize round-trips, profile-switch clears cleanly. obdcore + diagnostics tests still 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:
@@ -73,21 +73,31 @@ python obd_reader.py COM5 --crank --dash-log crank.csv # + record a CSV
|
|||||||
under the line), that's the high-pressure oil bleed-off — STC fitting / oil-rail
|
under the line), that's the high-pressure oil bleed-off — STC fitting / oil-rail
|
||||||
O-rings. On exit it prints the peak and a verdict. `q` quits, `r` resets.
|
O-rings. On exit it prints the peak and a verdict. `q` quits, `r` resets.
|
||||||
|
|
||||||
## Graphical app (preview — P1)
|
## Graphical app (preview)
|
||||||
|
|
||||||
A cross-platform desktop GUI (PySide6 + pyqtgraph) is in progress. P1 = PID
|
A cross-platform desktop GUI (PySide6 + pyqtgraph). Vehicle-agnostic — all PIDs,
|
||||||
browser + live overlay plot; see [ARCHITECTURE.md](ARCHITECTURE.md) for the
|
scaling, DTCs, and presets come from the JSON profiles in `profiles/`.
|
||||||
roadmap (cranking/driving/diagnostics perspectives, record/playback, etc.).
|
|
||||||
|
|
||||||
```
|
```
|
||||||
pip install -r requirements-gui.txt
|
pip install -r requirements-gui.txt
|
||||||
python run_gui.py # tick "Mock" + Connect to explore with no adapter
|
python run_gui.py # tick "Mock" + Connect to explore with no adapter
|
||||||
```
|
```
|
||||||
|
|
||||||

|
Features so far:
|
||||||
|
- **PID browser** (left) grouped by system, live values, confidence badges
|
||||||
|
- **Graph view** with **true multi-axis** overlay — one Y scale per unit (psi/V/rpm/…),
|
||||||
|
or a Normalize (% of range) mode
|
||||||
|
- **Gauge view** — arc gauges with peak-hold, one per signal
|
||||||
|
- **Table view** — value + min/max + confidence
|
||||||
|
- **Diagnostics** — read/clear DTCs (guarded), no-start codes flagged
|
||||||
|
- **Profile menu** — switch/import/edit vehicles; **File menu** — record/replay/export captures
|
||||||
|
|
||||||
The whole app runs against simulated data (`MockLink`) so it can be developed
|

|
||||||
on any machine and only needs the truck for real captures.
|

|
||||||
|
|
||||||
|
The whole app runs against simulated data (`MockLink`), so it can be developed
|
||||||
|
on any machine and only needs the vehicle for real captures. See
|
||||||
|
[ARCHITECTURE.md](ARCHITECTURE.md) for the roadmap.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB |
+66
-30
@@ -18,6 +18,7 @@ import pyqtgraph as pg
|
|||||||
from obdcore import (list_profiles, profiles_dir, save_profile, load_profile,
|
from obdcore import (list_profiles, profiles_dir, save_profile, load_profile,
|
||||||
export_csv, replay_csv, TimeSeriesStore)
|
export_csv, replay_csv, TimeSeriesStore)
|
||||||
from .controller import Controller
|
from .controller import Controller
|
||||||
|
from .widgets import MultiAxisPlot, SinglePlot, GaugeGrid
|
||||||
|
|
||||||
PLOT_WINDOW_S = 60.0
|
PLOT_WINDOW_S = 60.0
|
||||||
REFRESH_MS = 100
|
REFRESH_MS = 100
|
||||||
@@ -86,8 +87,8 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
self.view_table = self._act(viewm, "Table View", lambda: self._set_view(1),
|
self.view_table = self._act(viewm, "Table View", lambda: self._set_view(1),
|
||||||
checkable=True)
|
checkable=True)
|
||||||
self.view_graph.setChecked(True)
|
self.view_graph.setChecked(True)
|
||||||
gauges = self._act(viewm, "Gauge View (P2)", lambda: None, checkable=True)
|
self.view_gauge = self._act(viewm, "Gauge View", lambda: self._set_view(2),
|
||||||
gauges.setEnabled(False)
|
checkable=True)
|
||||||
viewm.addSeparator()
|
viewm.addSeparator()
|
||||||
self.show_pids = self._act(viewm, "Show PID Panel", self._toggle_pid_dock,
|
self.show_pids = self._act(viewm, "Show PID Panel", self._toggle_pid_dock,
|
||||||
checkable=True)
|
checkable=True)
|
||||||
@@ -352,32 +353,43 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
def _build_center(self):
|
def _build_center(self):
|
||||||
self.stack = QtWidgets.QStackedWidget()
|
self.stack = QtWidgets.QStackedWidget()
|
||||||
|
|
||||||
# graph page
|
# graph page: a sub-stack of multi-axis (raw) and single-axis (normalized)
|
||||||
gpage = QtWidgets.QWidget(); gl = QtWidgets.QVBoxLayout(gpage)
|
gpage = QtWidgets.QWidget(); gl = QtWidgets.QVBoxLayout(gpage)
|
||||||
gl.setContentsMargins(4, 4, 4, 4)
|
gl.setContentsMargins(4, 4, 4, 4)
|
||||||
bar = QtWidgets.QHBoxLayout()
|
bar = QtWidgets.QHBoxLayout()
|
||||||
self.norm_chk = QtWidgets.QCheckBox("Normalize (% of range)")
|
self.norm_chk = QtWidgets.QCheckBox("Normalize (% of range)")
|
||||||
self.norm_chk.toggled.connect(lambda v: self.norm_act.setChecked(v))
|
self.norm_chk.toggled.connect(self._set_normalize)
|
||||||
bar.addWidget(self.norm_chk); bar.addStretch(1)
|
bar.addWidget(self.norm_chk)
|
||||||
|
bar.addWidget(QtWidgets.QLabel(" (off = true multi-axis, one Y scale per unit)"))
|
||||||
|
bar.addStretch(1)
|
||||||
bar.addWidget(QtWidgets.QLabel(f"window: {int(PLOT_WINDOW_S)}s"))
|
bar.addWidget(QtWidgets.QLabel(f"window: {int(PLOT_WINDOW_S)}s"))
|
||||||
gl.addLayout(bar)
|
gl.addLayout(bar)
|
||||||
self.plot = pg.PlotWidget()
|
self.multi = MultiAxisPlot()
|
||||||
self.plot.addLegend(offset=(10, 10))
|
self.single = SinglePlot()
|
||||||
self.plot.showGrid(x=True, y=True, alpha=0.25)
|
self.graph_stack = QtWidgets.QStackedWidget()
|
||||||
self.plot.setLabel("bottom", "time", units="s")
|
self.graph_stack.addWidget(self.multi) # index 0 = multi-axis
|
||||||
gl.addWidget(self.plot)
|
self.graph_stack.addWidget(self.single) # index 1 = normalized
|
||||||
self.stack.addWidget(gpage)
|
gl.addWidget(self.graph_stack)
|
||||||
|
self.stack.addWidget(gpage) # center index 0 = graph
|
||||||
|
|
||||||
# table page
|
# table page (center index 1)
|
||||||
self.table = QtWidgets.QTableWidget(0, 6)
|
self.table = QtWidgets.QTableWidget(0, 6)
|
||||||
self.table.setHorizontalHeaderLabels(["Signal", "Value", "Unit", "Min", "Max", "Conf"])
|
self.table.setHorizontalHeaderLabels(["Signal", "Value", "Unit", "Min", "Max", "Conf"])
|
||||||
self.table.horizontalHeader().setStretchLastSection(True)
|
self.table.horizontalHeader().setStretchLastSection(True)
|
||||||
self.table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
|
self.table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
|
||||||
self.stack.addWidget(self.table)
|
self.stack.addWidget(self.table)
|
||||||
|
|
||||||
|
# gauge page (center index 2)
|
||||||
|
self.gauges = GaugeGrid()
|
||||||
|
self.stack.addWidget(self.gauges)
|
||||||
|
|
||||||
self.setCentralWidget(self.stack)
|
self.setCentralWidget(self.stack)
|
||||||
self._apply_theme()
|
self._apply_theme()
|
||||||
|
|
||||||
|
def _graph(self):
|
||||||
|
"""The active graph widget (multi-axis unless Normalize is on)."""
|
||||||
|
return self.single if self.norm_chk.isChecked() else self.multi
|
||||||
|
|
||||||
def _build_statusbar(self):
|
def _build_statusbar(self):
|
||||||
self.status = self.statusBar()
|
self.status = self.statusBar()
|
||||||
self.status.showMessage("Not connected. Pick a port (or Mock) and Connect.")
|
self.status.showMessage("Not connected. Pick a port (or Mock) and Connect.")
|
||||||
@@ -404,6 +416,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
QtWidgets.QMessageBox.critical(self, "Profile load failed", str(e))
|
QtWidgets.QMessageBox.critical(self, "Profile load failed", str(e))
|
||||||
return
|
return
|
||||||
self.curves.clear(); self._color_i = 0
|
self.curves.clear(); self._color_i = 0
|
||||||
|
self.multi.clear(); self.single.clear(); self.gauges.rebuild([])
|
||||||
self._rebuild_for_profile()
|
self._rebuild_for_profile()
|
||||||
self._rebuild_profile_menu()
|
self._rebuild_profile_menu()
|
||||||
self.status.showMessage(f"Loaded profile: {self.ctl.profile.name}")
|
self.status.showMessage(f"Loaded profile: {self.ctl.profile.name}")
|
||||||
@@ -538,25 +551,42 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
def _add_curve(self, key):
|
def _add_curve(self, key):
|
||||||
p = self.ctl.reg.get(key)
|
p = self.ctl.reg.get(key)
|
||||||
color = CURVE_COLORS[self._color_i % len(CURVE_COLORS)]; self._color_i += 1
|
color = CURVE_COLORS[self._color_i % len(CURVE_COLORS)]; self._color_i += 1
|
||||||
self.curves[key] = self.plot.plot([], [], name=f"{p.name} ({p.unit})",
|
self.curves[key] = color # curves maps key -> color
|
||||||
pen=pg.mkPen(color=color, width=2))
|
self._graph().add_curve(key, f"{p.name} ({p.unit})", p.unit, color)
|
||||||
|
self._refresh_gauges()
|
||||||
|
|
||||||
def _remove_curve(self, key):
|
def _remove_curve(self, key):
|
||||||
c = self.curves.pop(key, None)
|
if self.curves.pop(key, None) is None:
|
||||||
if c is not None:
|
return
|
||||||
self.plot.removeItem(c)
|
self.multi.remove_curve(key)
|
||||||
leg = self.plot.plotItem.legend
|
self.single.remove_curve(key)
|
||||||
if leg:
|
self._refresh_gauges()
|
||||||
try:
|
|
||||||
leg.removeItem(c)
|
def _set_normalize(self, on):
|
||||||
except Exception:
|
"""Swap between true multi-axis (raw) and single-axis (% of range)."""
|
||||||
pass
|
self.norm_act.setChecked(on)
|
||||||
|
self.multi.clear(); self.single.clear()
|
||||||
|
self.graph_stack.setCurrentIndex(1 if on else 0)
|
||||||
|
active = self._graph()
|
||||||
|
for key, color in self.curves.items():
|
||||||
|
p = self.ctl.reg.get(key)
|
||||||
|
active.add_curve(key, f"{p.name} ({p.unit})", p.unit, color)
|
||||||
|
self._apply_theme()
|
||||||
|
self._redraw_curves(static=not self.ctl.connected)
|
||||||
|
|
||||||
|
def _refresh_gauges(self):
|
||||||
|
specs = []
|
||||||
|
for key, color in self.curves.items():
|
||||||
|
p = self.ctl.reg.get(key)
|
||||||
|
specs.append((key, p.name, p.unit, p.vmin, p.vmax, color))
|
||||||
|
self.gauges.rebuild(specs)
|
||||||
|
|
||||||
# ---------- view ----------
|
# ---------- view ----------
|
||||||
def _set_view(self, idx):
|
def _set_view(self, idx):
|
||||||
self.stack.setCurrentIndex(idx)
|
self.stack.setCurrentIndex(idx)
|
||||||
self.view_graph.setChecked(idx == 0)
|
self.view_graph.setChecked(idx == 0)
|
||||||
self.view_table.setChecked(idx == 1)
|
self.view_table.setChecked(idx == 1)
|
||||||
|
self.view_gauge.setChecked(idx == 2)
|
||||||
|
|
||||||
def _toggle_pid_dock(self):
|
def _toggle_pid_dock(self):
|
||||||
self.pid_dock.setVisible(self.show_pids.isChecked())
|
self.pid_dock.setVisible(self.show_pids.isChecked())
|
||||||
@@ -569,10 +599,9 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
self._apply_theme()
|
self._apply_theme()
|
||||||
|
|
||||||
def _apply_theme(self):
|
def _apply_theme(self):
|
||||||
bg, fg = THEMES[self._theme]
|
bg, _fg = THEMES[self._theme]
|
||||||
self.plot.setBackground(bg)
|
self.multi.set_background(bg)
|
||||||
self.plot.getAxis("bottom").setPen(fg); self.plot.getAxis("left").setPen(fg)
|
self.single.set_background(bg)
|
||||||
self.plot.getAxis("bottom").setTextPen(fg); self.plot.getAxis("left").setTextPen(fg)
|
|
||||||
|
|
||||||
def _populate_table_rows(self):
|
def _populate_table_rows(self):
|
||||||
pids = self.ctl.reg.all()
|
pids = self.ctl.reg.all()
|
||||||
@@ -661,7 +690,8 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
else:
|
else:
|
||||||
since = (self.ctl.t0 or 0) + max(0.0, self.ctl.now() - PLOT_WINDOW_S)
|
since = (self.ctl.t0 or 0) + max(0.0, self.ctl.now() - PLOT_WINDOW_S)
|
||||||
normalize = self.norm_chk.isChecked()
|
normalize = self.norm_chk.isChecked()
|
||||||
for key, curve in self.curves.items():
|
active = self._graph()
|
||||||
|
for key in self.curves:
|
||||||
p = self.ctl.reg.get(key)
|
p = self.ctl.reg.get(key)
|
||||||
xs, ys = [], []
|
xs, ys = [], []
|
||||||
for t, v in self.ctl.store.channel(key).series(since=since):
|
for t, v in self.ctl.store.channel(key).series(since=since):
|
||||||
@@ -672,8 +702,9 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
ys.append((v - p.vmin) / (p.vmax - p.vmin) * 100.0)
|
ys.append((v - p.vmin) / (p.vmax - p.vmin) * 100.0)
|
||||||
else:
|
else:
|
||||||
ys.append(v)
|
ys.append(v)
|
||||||
curve.setData(xs, ys)
|
active.set_data(key, xs, ys)
|
||||||
self.plot.setLabel("left", "% of range" if normalize else "value")
|
if normalize:
|
||||||
|
self.single.set_y_label("% of range")
|
||||||
|
|
||||||
def _tick(self):
|
def _tick(self):
|
||||||
if not self.ctl.connected:
|
if not self.ctl.connected:
|
||||||
@@ -691,6 +722,11 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
self.table.item(r, 3).setText("--" if lo is None else f"{lo:g}")
|
self.table.item(r, 3).setText("--" if lo is None else f"{lo:g}")
|
||||||
self.table.item(r, 4).setText("--" if hi is None else f"{hi:g}")
|
self.table.item(r, 4).setText("--" if hi is None else f"{hi:g}")
|
||||||
self.tree.blockSignals(False)
|
self.tree.blockSignals(False)
|
||||||
|
if self.stack.currentIndex() == 2: # gauge view
|
||||||
|
for key in self.curves:
|
||||||
|
lo, hi = self.ctl.store.minmax(key)
|
||||||
|
self.gauges.set_value(key, self.ctl.store.latest(key), peak=hi)
|
||||||
|
else:
|
||||||
self._redraw_curves()
|
self._redraw_curves()
|
||||||
|
|
||||||
def closeEvent(self, ev):
|
def closeEvent(self, ev):
|
||||||
|
|||||||
+245
@@ -0,0 +1,245 @@
|
|||||||
|
"""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)
|
||||||
Reference in New Issue
Block a user